@webjsdev/server 0.8.63 → 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.
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 +1 -1
  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
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
- /** Scan a directory's source for bare-specifier npm imports. */
340
- export declare function scanBareImports(dir: string, skipFiles?: Set<string>): Promise<Set<string>>;
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.63",
3
+ "version": "0.8.64",
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.50",
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
+ }