@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.
- package/index.d.ts +7 -2
- 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 +173 -26
- 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/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
package/index.d.ts
CHANGED
|
@@ -336,8 +336,13 @@ export declare function setVendorEntries(
|
|
|
336
336
|
// vendor.js (the `webjs vendor` CLI surface)
|
|
337
337
|
// ---------------------------------------------------------------------------
|
|
338
338
|
|
|
339
|
-
/**
|
|
340
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Bare-specifier npm imports an app could load in the browser, found by walking
|
|
341
|
+
* the module graph from every browser-bound entry rather than by scanning the
|
|
342
|
+
* directory. Applies no elision pruning, so the result is a superset of what the
|
|
343
|
+
* running server serves.
|
|
344
|
+
*/
|
|
345
|
+
export declare function scanBareImports(dir: string): Promise<Set<string>>;
|
|
341
346
|
/** Extract the package name from an import specifier (`dayjs/plugin/utc` -> `dayjs`). */
|
|
342
347
|
export declare function extractPackageName(spec: string): string | null;
|
|
343
348
|
/** Resolve bare imports to CDN importmap entries. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.65",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@webjsdev/core": "^0.7.
|
|
31
|
+
"@webjsdev/core": "^0.7.51",
|
|
32
32
|
"ws": "^8.20.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser-bound ENTRY files of an app: every module the client boot can
|
|
3
|
+
* import directly.
|
|
4
|
+
*
|
|
5
|
+
* Feeding these to `reachableFromEntries` produces webjs's equivalent of
|
|
6
|
+
* Next.js's bundler-produced page manifest, derived lazily on the first
|
|
7
|
+
* request (and re-derived on every rebuild) instead of at compile time. The
|
|
8
|
+
* dev server uses that closure as an authorization gate on its source-file
|
|
9
|
+
* branch: in-set is served (subject to the `.server.{js,ts}` stub guardrail),
|
|
10
|
+
* out-of-set 404s. Feeding them to `reachableBareSpecifiers` produces the
|
|
11
|
+
* vendor importmap, which is why this half is its own module: the CLI-side
|
|
12
|
+
* vendor paths (`pinAll`, `webjs doctor`) root at exactly the entries the gate
|
|
13
|
+
* does, without booting a server.
|
|
14
|
+
*
|
|
15
|
+
* Entries:
|
|
16
|
+
* - page / layout (both re-run on the client for hydration)
|
|
17
|
+
* - the error / loading / not-found / forbidden / unauthorized boundaries
|
|
18
|
+
* - the two root-only boundaries (global-error, global-not-found)
|
|
19
|
+
* - instrumentation-client (imported first in the boot)
|
|
20
|
+
* - every discovered component (a `static lazy` one is fetched by the lazy
|
|
21
|
+
* loader rather than imported by a page, so the component scan is an entry
|
|
22
|
+
* source in its own right)
|
|
23
|
+
*
|
|
24
|
+
* NOT entries, because the browser never fetches them as a module:
|
|
25
|
+
* - route.{js,ts} (API handlers) and middleware.{js,ts}
|
|
26
|
+
* - metadata routes (sitemap.js, robots.js, manifest.js, …)
|
|
27
|
+
* - .server.{js,ts} files (the browser gets a stub, not the source)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Components are passed in (rather than rescanned) so the caller can share one
|
|
32
|
+
* scan with `primeComponentRegistry`, saving a full appDir walk per analysis.
|
|
33
|
+
*
|
|
34
|
+
* @param {Awaited<ReturnType<typeof import('./router.js').buildRouteTable>>} routeTable
|
|
35
|
+
* @param {Awaited<ReturnType<typeof import('./component-scanner.js').scanComponents>>} components
|
|
36
|
+
* @returns {Set<string>}
|
|
37
|
+
*/
|
|
38
|
+
export function browserEntryFiles(routeTable, components) {
|
|
39
|
+
/** @type {Set<string>} */
|
|
40
|
+
const entries = new Set();
|
|
41
|
+
for (const page of routeTable.pages) {
|
|
42
|
+
if (page.file) entries.add(page.file);
|
|
43
|
+
for (const f of page.layouts || []) entries.add(f);
|
|
44
|
+
for (const f of page.errors || []) entries.add(f);
|
|
45
|
+
for (const f of page.loadings || []) entries.add(f);
|
|
46
|
+
for (const f of page.forbiddens || []) entries.add(f);
|
|
47
|
+
for (const f of page.unauthorizeds || []) entries.add(f);
|
|
48
|
+
}
|
|
49
|
+
if (routeTable.notFound) entries.add(routeTable.notFound);
|
|
50
|
+
if (routeTable.notFounds) {
|
|
51
|
+
for (const f of routeTable.notFounds.values()) entries.add(f);
|
|
52
|
+
}
|
|
53
|
+
if (routeTable.globalError) entries.add(routeTable.globalError);
|
|
54
|
+
if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
|
|
55
|
+
// instrumentation-client is browser-bound (imported first in the boot), so it
|
|
56
|
+
// must be servable through the gate.
|
|
57
|
+
if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
|
|
58
|
+
// Lazy components live in the registry but no page imports their
|
|
59
|
+
// class directly; the lazy-loader fetches their module URLs on
|
|
60
|
+
// viewport entry. Add every discovered component file as an entry so
|
|
61
|
+
// the graph walk covers both eager and lazy paths.
|
|
62
|
+
for (const c of components) entries.add(c.file);
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { sep } from 'node:path';
|
|
2
|
+
import { matchClosingBrace } from '../js-scan.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check whether a file has the `'use server'` directive in its first
|
|
6
|
+
* five lines. Used by the `use-server-needs-extension` rule, and by
|
|
7
|
+
* `isServerActionFile` below.
|
|
8
|
+
* @param {string} content file content (already read)
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function hasUseServerDirective(content) {
|
|
12
|
+
const head = content.split('\n').slice(0, 5).join('\n');
|
|
13
|
+
return /^\s*(['"])use server\1\s*;?\s*$/m.test(head);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check whether a file is a server action. A server action requires
|
|
18
|
+
* BOTH the `.server.{js,ts,mts,mjs}` extension AND the `'use server'`
|
|
19
|
+
* directive in the file head. Either alone is not enough: bare `.server.ts`
|
|
20
|
+
* is a server-only utility (no RPC), and bare `'use server'` is a lint
|
|
21
|
+
* violation (use-server-needs-extension).
|
|
22
|
+
* @param {string} filePath absolute path
|
|
23
|
+
* @param {string} content file content (already read)
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isServerActionFile(filePath, content) {
|
|
27
|
+
if (!/\.server\.m?[jt]s$/.test(filePath)) return false;
|
|
28
|
+
return hasUseServerDirective(content);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check whether a file resides under a components/ directory (shared or
|
|
33
|
+
* module-scoped).
|
|
34
|
+
* @param {string} relPath Path relative to appDir
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function isComponentFile(relPath) {
|
|
38
|
+
const segments = relPath.split(sep);
|
|
39
|
+
return segments.includes('components');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Scan a class body for class-field initializers naming any of `props`.
|
|
44
|
+
* "Class-field" means: at the top of the class body (brace depth 0
|
|
45
|
+
* relative to the body), at the start of a line, NOT prefixed with
|
|
46
|
+
* `declare`, `static`, or `this.`.
|
|
47
|
+
*
|
|
48
|
+
* Returns the offending property names. The caller maps these to
|
|
49
|
+
* Violation objects.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} classBody
|
|
52
|
+
* @param {Set<string>} props
|
|
53
|
+
* @returns {string[]}
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* True when a factory prop value is an array-typed `prop<…>(…)` whose
|
|
57
|
+
* runtime constructor argument is `Object`. The generic and the
|
|
58
|
+
* constructor sit in the same call, so it is decidable from the value
|
|
59
|
+
* text alone. The match is greedy on the generic so a nested generic
|
|
60
|
+
* (`prop<Array<X>>(Object)`) closes at the outer `>` that precedes the
|
|
61
|
+
* `(Object` call. A bare constructor (`Object`, with no generic) or a
|
|
62
|
+
* non-array generic (`prop<Foo>(Object)`) returns false.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} value the raw prop value text, e.g. `prop<Tag[]>(Object)`
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function arrayPropUsesObject(value) {
|
|
68
|
+
const m = /^prop\s*<([\s\S]*)>\s*\(\s*Object\s*[,)]/.exec(value.trim());
|
|
69
|
+
if (!m) return false;
|
|
70
|
+
return isArrayTypeText(m[1]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when a TypeScript type expression denotes an array: `T[]`,
|
|
75
|
+
* `readonly T[]`, `T[][]`, `Array<T>`, or `ReadonlyArray<T>`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} type
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function isArrayTypeText(type) {
|
|
81
|
+
const bare = type.trim().replace(/^readonly\s+/, '');
|
|
82
|
+
if (/\[\s*\]$/.test(bare)) return true;
|
|
83
|
+
if (/^(?:Readonly)?Array\s*<[\s\S]*>$/.test(bare)) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function findFieldInitializers(classBody, props) {
|
|
88
|
+
/** @type {string[]} */
|
|
89
|
+
const out = [];
|
|
90
|
+
const n = classBody.length;
|
|
91
|
+
// Match class-field declarations. Two shapes:
|
|
92
|
+
// 1. With initializer: `count = 0`, `count: number = 0`, `public count = 0`
|
|
93
|
+
// 2. Type-only (no initializer): `count!: number`, `count?: number`, `count: number`
|
|
94
|
+
// Both compile to Object.defineProperty after super() under modern class-field
|
|
95
|
+
// semantics, clobbering the reactive accessor.
|
|
96
|
+
// The initializer regex: optional modifier, identifier, optional type, then `=`.
|
|
97
|
+
const initRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
|
|
98
|
+
// The type-only regex: optional modifier, identifier, then `!:` or `?:` or `:` with a type.
|
|
99
|
+
const typeOnlyRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*[!?]?\s*:\s*\S/;
|
|
100
|
+
const examineLine = (lineStart) => {
|
|
101
|
+
let j = lineStart;
|
|
102
|
+
while (j < n && classBody[j] !== '\n') j++;
|
|
103
|
+
const line = classBody.slice(lineStart, j);
|
|
104
|
+
const initM = initRe.exec(line);
|
|
105
|
+
const typeM = typeOnlyRe.exec(line);
|
|
106
|
+
// Prefer the initializer match; if neither, skip.
|
|
107
|
+
const name = initM ? initM[2] : (typeM ? typeM[2] : null);
|
|
108
|
+
// `declare`, `static`, and `this.` patterns shouldn't reach here
|
|
109
|
+
// (declare/static start with their keyword, this.x has the dot in
|
|
110
|
+
// the regex group), but guard against matching keywords as names:
|
|
111
|
+
if (name && name !== 'declare' && name !== 'static' && props.has(name)) out.push(name);
|
|
112
|
+
};
|
|
113
|
+
// Walk the body char by char, tracking brace depth. A class field lives at
|
|
114
|
+
// the class-body top level (depth 0). We examine a line ONCE, at its first
|
|
115
|
+
// non-whitespace char, only while depth is 0, and WITHOUT skipping the braces
|
|
116
|
+
// on that line: an opening brace on the line (`method() {`, `field = {`) must
|
|
117
|
+
// still be counted so the lines inside its block are seen at depth > 0. The
|
|
118
|
+
// earlier version jumped `i` past the whole examined line, which dropped that
|
|
119
|
+
// line's braces and let object-literal keys inside a method body (`{ game:
|
|
120
|
+
// ..., scoreboard: ... }`) be misread as depth-0 class fields (#934).
|
|
121
|
+
let depth = 0;
|
|
122
|
+
let i = 0;
|
|
123
|
+
let lineStart = 0;
|
|
124
|
+
let examined = false;
|
|
125
|
+
let str = '';
|
|
126
|
+
while (i < n) {
|
|
127
|
+
const c = classBody[i];
|
|
128
|
+
if (str) {
|
|
129
|
+
if (c === '\\') { i += 2; continue; }
|
|
130
|
+
if (c === str) str = '';
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === '\n') { lineStart = i + 1; examined = false; i++; continue; }
|
|
135
|
+
if (c === '/' && classBody[i + 1] === '/') {
|
|
136
|
+
while (i < n && classBody[i] !== '\n') i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (c === '/' && classBody[i + 1] === '*') {
|
|
140
|
+
i += 2;
|
|
141
|
+
while (i < n && !(classBody[i] === '*' && classBody[i + 1] === '/')) i++;
|
|
142
|
+
i += 2;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
|
|
146
|
+
// First non-whitespace char of a class-body top-level line: examine it for
|
|
147
|
+
// a field declaration BEFORE consuming any brace it opens.
|
|
148
|
+
if (depth === 0 && !examined && !/\s/.test(c)) {
|
|
149
|
+
examined = true;
|
|
150
|
+
examineLine(lineStart);
|
|
151
|
+
}
|
|
152
|
+
if (c === '{') { depth++; i++; continue; }
|
|
153
|
+
if (c === '}') { depth--; i++; continue; }
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Browser-only globals that are undefined during SSR (the server-side
|
|
160
|
+
// WebComponent base is a bare class with no DOM). High-confidence names only
|
|
161
|
+
// (unlikely to be ordinary local variables), so the rule stays low-noise.
|
|
162
|
+
const BROWSER_GLOBALS = [
|
|
163
|
+
'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
|
|
164
|
+
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
165
|
+
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
// HTMLElement instance members that do not exist on the server element shim,
|
|
169
|
+
// so `this.<member>` throws (a method call) or is `undefined` (a property) at
|
|
170
|
+
// SSR. The attribute methods (get/set/has/remove/toggleAttribute), the event
|
|
171
|
+
// methods (add/removeEventListener, dispatchEvent), and attachInternals are
|
|
172
|
+
// backed by the shim and run server-side, so they are intentionally NOT
|
|
173
|
+
// flagged: a component may read attributes in render and reflect properties
|
|
174
|
+
// during the SSR update cycle. What stays is the genuinely browser-only
|
|
175
|
+
// surface (DOM querying, layout reads, shadow construction, focus).
|
|
176
|
+
const HTMLELEMENT_MEMBERS = [
|
|
177
|
+
'attachShadow', 'shadowRoot', 'classList',
|
|
178
|
+
'querySelector', 'querySelectorAll', 'getBoundingClientRect',
|
|
179
|
+
'focus', 'blur', 'scrollIntoView',
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract the body text of a named method from a (redacted) class body, or
|
|
184
|
+
* '' if absent. Handles `async`, a TS return-type annotation, and params.
|
|
185
|
+
* @param {string} classBody
|
|
186
|
+
* @param {string} name
|
|
187
|
+
*/
|
|
188
|
+
export function methodBodyOf(classBody, name) {
|
|
189
|
+
const re = new RegExp(`(?:^|[\\s;}])(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`, 'g');
|
|
190
|
+
const m = re.exec(classBody);
|
|
191
|
+
if (!m) return '';
|
|
192
|
+
const open = classBody.indexOf('{', m.index + m[0].length - 1);
|
|
193
|
+
if (open === -1) return '';
|
|
194
|
+
const close = matchClosingBrace(classBody, open + 1);
|
|
195
|
+
return close === -1 ? '' : classBody.slice(open + 1, close);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Find browser-only globals and HTMLElement `this.<member>` accesses in a
|
|
200
|
+
* (redacted) method body. Returns one entry per distinct member.
|
|
201
|
+
* @param {string} code
|
|
202
|
+
* @returns {{ member: string, kind: string }[]}
|
|
203
|
+
*/
|
|
204
|
+
export function findBrowserMemberUses(code) {
|
|
205
|
+
// The class body arrives template-redacted, but `redactStringsAndTemplates`
|
|
206
|
+
// keeps single/double-quoted string CONTENT (real specifiers ride strings).
|
|
207
|
+
// Blank that too so a browser word inside a string literal (e.g. a label
|
|
208
|
+
// `'open the document'`) is not mistaken for a real global access.
|
|
209
|
+
code = code
|
|
210
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, (s) => `'${' '.repeat(Math.max(0, s.length - 2))}'`)
|
|
211
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, (s) => `"${' '.repeat(Math.max(0, s.length - 2))}"`);
|
|
212
|
+
const out = [];
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const gRe = new RegExp(`(?<![.\\w$])(${BROWSER_GLOBALS.join('|')})\\b`, 'g');
|
|
215
|
+
let m;
|
|
216
|
+
while ((m = gRe.exec(code)) !== null) {
|
|
217
|
+
if (seen.has(m[1])) continue;
|
|
218
|
+
seen.add(m[1]);
|
|
219
|
+
out.push({ member: m[1], kind: 'a browser global' });
|
|
220
|
+
}
|
|
221
|
+
const hRe = new RegExp(`\\bthis\\.(${HTMLELEMENT_MEMBERS.join('|')})\\b`, 'g');
|
|
222
|
+
while ((m = hRe.exec(code)) !== null) {
|
|
223
|
+
const key = `this.${m[1]}`;
|
|
224
|
+
if (seen.has(key)) continue;
|
|
225
|
+
seen.add(key);
|
|
226
|
+
out.push({ member: key, kind: 'an HTMLElement member' });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* True if any `export const/let/var` declares more than one binding
|
|
233
|
+
* (`export const a = 1, b = 2`), which the single-name collector would
|
|
234
|
+
* under-count. Depth-aware: a comma inside an initializer (`f(a, b)`, `[a, b]`,
|
|
235
|
+
* `{ a, b }`) does not count, only a top-level comma before the statement end.
|
|
236
|
+
* Bailing the whole module on this errs toward a false negative (safe), never a
|
|
237
|
+
* false positive. Runs on the string/comment-redacted `scan`.
|
|
238
|
+
* @param {string} scan
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
export function hasMultiDeclaratorExport(scan) {
|
|
242
|
+
const re = /\bexport\s+(?:const|let|var)\b/g;
|
|
243
|
+
let m;
|
|
244
|
+
while ((m = re.exec(scan))) {
|
|
245
|
+
let depth = 0; // () [] {} nesting
|
|
246
|
+
let angle = 0; // <> generics, tracked ONLY inside a type annotation
|
|
247
|
+
let seenEq = false; // passed this declarator's `=` (now in the initializer)
|
|
248
|
+
let inType = false; // inside a `: Type` annotation (before the `=`)
|
|
249
|
+
for (let i = m.index + m[0].length; i < scan.length; i++) {
|
|
250
|
+
const ch = scan[i];
|
|
251
|
+
if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
|
|
252
|
+
if (ch === ')' || ch === ']' || ch === '}') { if (depth === 0) break; depth--; continue; }
|
|
253
|
+
if (depth !== 0) continue;
|
|
254
|
+
if (ch === ';') break;
|
|
255
|
+
// A plain `=` ends the type annotation and enters the initializer. `<=`,
|
|
256
|
+
// `>=`, `=>`, `==` are harmless here (seenEq only latches true).
|
|
257
|
+
if (ch === '=') { seenEq = true; inType = false; angle = 0; continue; }
|
|
258
|
+
// A `:` BEFORE the `=` opens the type annotation. A `:` after `=` (a
|
|
259
|
+
// ternary in the initializer) is NOT a type and must not suppress a real
|
|
260
|
+
// declarator comma.
|
|
261
|
+
if (ch === ':' && !seenEq) { inType = true; continue; }
|
|
262
|
+
// Inside a type annotation `<` / `>` are generic delimiters (a comparison
|
|
263
|
+
// only appears in the initializer, after `=`). Track their depth so a
|
|
264
|
+
// generic's comma (`Map<string, number>`) is skipped while a real
|
|
265
|
+
// declarator comma (`a: number, b: number`) still counts. A `>` that is
|
|
266
|
+
// part of a `=>` function-type arrow is not a generic close.
|
|
267
|
+
if (inType && ch === '<') { angle++; continue; }
|
|
268
|
+
if (inType && ch === '>' && scan[i - 1] !== '=') { if (angle > 0) angle--; continue; }
|
|
269
|
+
// A top-level comma starts a second declarator UNLESS it is a generic's
|
|
270
|
+
// comma inside a type annotation.
|
|
271
|
+
if (ch === ',' && !(inType && angle > 0)) return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Enumerate a module's provable named exports from its redacted `scan`, for the
|
|
279
|
+
* `no-missing-local-import` rule. Returns `null` when the export list is NOT
|
|
280
|
+
* fully enumerable (a `export *` star re-export, a destructuring export, or a
|
|
281
|
+
* multi-declarator `export const a = .., b = ..`), which tells the rule to treat
|
|
282
|
+
* the module as unknowable and flag nothing from it. Otherwise returns the set
|
|
283
|
+
* of exported names (the alias after `as` for a clause, and type/interface/enum
|
|
284
|
+
* names too, so a value import of a type is not falsely flagged; tsc owns that).
|
|
285
|
+
* @param {string} scan string/template/comment-redacted source
|
|
286
|
+
* @returns {Set<string>|null}
|
|
287
|
+
*/
|
|
288
|
+
export function enumerableExports(scan) {
|
|
289
|
+
// Unknowable export shapes: bail so the rule never false-positives.
|
|
290
|
+
if (/\bexport\s*\*/.test(scan)) return null; // export * from
|
|
291
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) return null; // destructuring export
|
|
292
|
+
if (hasMultiDeclaratorExport(scan)) return null; // export const a=1, b=2
|
|
293
|
+
const names = new Set();
|
|
294
|
+
let m;
|
|
295
|
+
const collect = (re) => { while ((m = re.exec(scan))) names.add(m[1]); };
|
|
296
|
+
collect(/\bexport\s+(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)/g);
|
|
297
|
+
collect(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g);
|
|
298
|
+
collect(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g);
|
|
299
|
+
collect(/\bexport\s+(?:type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)/g);
|
|
300
|
+
// A named-default import (`import { default as Foo }`) is legal against an
|
|
301
|
+
// `export default` module, so record the `default` name when present.
|
|
302
|
+
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
303
|
+
// `export { a, b as c }` / `export type { ... }` / `export { x } from '...'`:
|
|
304
|
+
// the EXPORTED name is the alias after `as`, else the bare name.
|
|
305
|
+
const reClause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
|
|
306
|
+
while ((m = reClause.exec(scan))) {
|
|
307
|
+
for (let part of m[1].split(',')) {
|
|
308
|
+
part = part.trim().replace(/^type\s+/, '');
|
|
309
|
+
if (!part) continue;
|
|
310
|
+
const name = part.split(/\s+as\s+/).pop().trim();
|
|
311
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return names;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The NAMED VALUE names a single `import { ... } from` clause pulls in, for the
|
|
319
|
+
* `no-missing-local-import` rule. Returns `null` for an `import type { ... }`
|
|
320
|
+
* (all type, skip), and `[]` for a default / namespace / side-effect import
|
|
321
|
+
* (nothing named to verify). A per-specifier inline `type` marker is dropped,
|
|
322
|
+
* and for `a as b` the IMPORTED name is `a` (what the target must export).
|
|
323
|
+
* @param {string} clause the text between `import` and `from`
|
|
324
|
+
* @returns {string[]|null}
|
|
325
|
+
*/
|
|
326
|
+
/**
|
|
327
|
+
* The `{ local, imported }` pairs a named import clause binds, for a rule that
|
|
328
|
+
* has to match the LOCAL name against code (`action=${reader}`) while reporting
|
|
329
|
+
* the IMPORTED one (`readIt`, which is what the target module calls it).
|
|
330
|
+
*
|
|
331
|
+
* `importedValueNames` cannot serve here: it returns only the imported side,
|
|
332
|
+
* which is the wrong name to match against a template hole under `as`.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} clause the text between `import` and `from`
|
|
335
|
+
* @returns {{ local: string, imported: string }[]}
|
|
336
|
+
*/
|
|
337
|
+
export function importedLocalNames(clause) {
|
|
338
|
+
if (/^\s*type\b/.test(clause)) return [];
|
|
339
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
340
|
+
if (!brace) return [];
|
|
341
|
+
/** @type {{ local: string, imported: string }[]} */
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const part of brace[1].split(',')) {
|
|
344
|
+
const seg = part.trim().replace(/^type\s+/, '');
|
|
345
|
+
if (!seg) continue;
|
|
346
|
+
const as = seg.split(/\s+as\s+/);
|
|
347
|
+
const imported = as[0].trim();
|
|
348
|
+
const local = (as[1] || as[0]).trim();
|
|
349
|
+
if (/^[A-Za-z_$][\w$]*$/.test(local) && /^[A-Za-z_$][\w$]*$/.test(imported)) {
|
|
350
|
+
out.push({ local, imported });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function importedValueNames(clause) {
|
|
357
|
+
if (/^\s*type\b/.test(clause)) return null; // import type { ... }
|
|
358
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
359
|
+
if (!brace) return []; // default / namespace only
|
|
360
|
+
return brace[1].split(',')
|
|
361
|
+
.map((s) => s.trim())
|
|
362
|
+
.filter((s) => s && !/^type\s/.test(s))
|
|
363
|
+
.map((s) => s.split(/\s+as\s+/)[0].trim())
|
|
364
|
+
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
|
|
365
|
+
}
|
|
@@ -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
|
+
}
|