@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,405 @@
1
+ /**
2
+ * Shared machinery behind the check rules: the module-graph walk for the
3
+ * server-import rule, the git-ignore probe, and the small fs predicates.
4
+ *
5
+ * Its own module rather than private to runner.js, because the rules call it
6
+ * and runner.js calls the rules. Parked in runner.js it made that a cycle.
7
+ */
8
+ import { readFile, stat } from 'node:fs/promises';
9
+ import { join, relative, basename } from 'node:path';
10
+ import { walk } from '../fs-walk.js';
11
+ import { buildModuleGraph, transitiveDeps } from '../module-graph.js';
12
+ import { scanComponents } from '../component-scanner.js';
13
+ import { buildRouteTable } from '../router.js';
14
+ import { analyzeElision } from '../component-elision.js';
15
+ import {
16
+ hasUseServerDirective,
17
+ } from './helpers.js';
18
+
19
+ /**
20
+ * @typedef {import('./rules.js').Violation} Violation
21
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
22
+ */
23
+
24
+ /**
25
+ * BFS the module graph for the shortest import path from `from` to `to`,
26
+ * returning every hop `[from, ..., to]` so the `no-server-import-in-browser-module`
27
+ * message can print the FULL chain instead of an opaque `… ->` truncation (#804).
28
+ * Falls back to `[from, to]` if no path is found (defensive; the caller only
29
+ * calls this once `to` is known reachable).
30
+ *
31
+ * @param {Map<string, Set<string>>} graph
32
+ * @param {string} from
33
+ * @param {string} to
34
+ * @returns {string[]}
35
+ */
36
+ export function findImportChain(graph, from, to) {
37
+ const prev = new Map();
38
+ const seen = new Set([from]);
39
+ const queue = [from];
40
+ while (queue.length) {
41
+ const cur = queue.shift();
42
+ const deps = graph.get(cur);
43
+ if (!deps) continue;
44
+ for (const dep of deps) {
45
+ if (seen.has(dep)) continue;
46
+ seen.add(dep);
47
+ prev.set(dep, cur);
48
+ if (dep === to) {
49
+ const path = [];
50
+ for (let n = to; n !== undefined; n = prev.get(n)) path.unshift(n);
51
+ return path;
52
+ }
53
+ queue.push(dep);
54
+ }
55
+ }
56
+ return [from, to];
57
+ }
58
+
59
+ /**
60
+ * Implements `no-server-import-in-browser-module`. Factored into its own
61
+ * function (rather than an inline block) because it does the heavier
62
+ * whole-app analysis the other rules avoid: it builds the module graph,
63
+ * scans components, builds the route table, and runs the framework's own
64
+ * elision analysis so the rule's notion of "ships to the browser" is
65
+ * byte-for-byte the build's.
66
+ *
67
+ * A module is flagged when BOTH hold:
68
+ * 1. It SHIPS to the browser. For a component that means it is NOT in the
69
+ * elidable set; for a page / layout that means it is NOT in the inert
70
+ * route-module set. (Pages and layouts that do real client work are not
71
+ * inert and therefore ship.)
72
+ * 2. Its transitive import closure reaches a `.server.{ts,js}` module.
73
+ * `transitiveDeps` stops AT a server file (it is included but not walked
74
+ * into), so a server file pulled in only through another server file is
75
+ * not attributed to a browser module that never reaches it directly.
76
+ *
77
+ * Also flagged: error / loading / not-found modules. These ship to the browser
78
+ * too (the dev server's `computeBrowserBoundFiles` adds them unconditionally)
79
+ * and are never elided, so a server import reaching one of them is the same
80
+ * throw-at-load crash.
81
+ *
82
+ * Never flagged: a `.server.ts` importing another `.server.ts` (server-to-
83
+ * server, and `.server.*` modules are not components nor route modules), and
84
+ * `middleware.ts` / `route.ts` (server-only, never page/layout/component
85
+ * entries, so they are not in the candidate set to begin with).
86
+ *
87
+ * Scope note for dynamic imports: a string-literal `import('./widget.ts')` IS
88
+ * tracked by the module graph now (#751), but only as a GATE edge (so the
89
+ * lazily-imported module is servable); this rule's server-import detection
90
+ * still runs over STATIC edges only (`transitiveDeps`), so a dynamic
91
+ * `import('./x.server.ts')` of a no-`'use server'` utility is not flagged here.
92
+ * That is deliberate: the throw-at-load crash is deferred to call time (when
93
+ * the module is actually fetched), not module load, and a dynamic import is
94
+ * also not elided framework-wide. A computed `import(expr)` cannot be resolved
95
+ * statically at all; rather than a false-positive-prone check rule (a computed
96
+ * import of an npm specifier or an otherwise-reachable app module is perfectly
97
+ * valid, so it fails the check-is-correctness-only dividing line), the dev
98
+ * server surfaces it with a 404 hint when the target 404s (see dev.js #751).
99
+ *
100
+ * @param {string} appDir
101
+ * @param {Violation[]} violations appended to in place
102
+ */
103
+ // --- Rule: no-server-import-in-browser-module ---
104
+ // A page / layout / component module that SHIPS to the browser must not
105
+ // transitively import a server-only `.server.{ts,js}` module. The browser
106
+ // gets a stub for the server file, so the import is harmless while the
107
+ // module never loads client-side: a display-only page is elided, and an
108
+ // import-only page (#605/#963) is dropped from the boot in favour of its
109
+ // components. But the moment the page does its OWN client work (the client
110
+ // router, a reactive primitive, module-scope code, a client-effecting util
111
+ // on a component-free path) it ships whole, must load in the browser,
112
+ // and drags the server import with it: the stub throws (or a server-only
113
+ // export like `auth` is missing) the instant the module loads. That crash
114
+ // only surfaces at runtime; typecheck and every other check pass.
115
+ //
116
+ // The rule reuses the BUILD'S elision verdict (analyzeElision) instead of
117
+ // re-deriving it, so it fires ONLY on modules that genuinely ship: a
118
+ // display-only page the framework elides is never flagged (that is the
119
+ // legitimate pattern). The motivating case (crisp dogfood): a page that does
120
+ // `await auth()` (import from `lib/auth.server.ts`) AND imports a component
121
+ // directly, so it is not elided and ships the server import.
122
+ export async function checkServerImportInBrowserModule(appDir, violations) {
123
+ // No `app/` directory means this is not a routable WebJs app (e.g. a bare
124
+ // component library, or a fixture with only `lib/`); nothing ships, so the
125
+ // rule has nothing to police. Skip rather than do the heavy analysis.
126
+ if (!(await pathExists(join(appDir, 'app')))) return;
127
+
128
+ let moduleGraph, components, routeTable;
129
+ try {
130
+ moduleGraph = await buildModuleGraph(appDir);
131
+ components = await scanComponents(appDir);
132
+ routeTable = await buildRouteTable(appDir);
133
+ } catch {
134
+ // A malformed app the analysis can't process is left to the other rules
135
+ // (and the dev server) to surface; this rule degrades to a no-op.
136
+ return;
137
+ }
138
+
139
+ // Page + layout modules that the router treats as route modules, exactly the
140
+ // set the dev server feeds to analyzeElision (so the inert verdict matches).
141
+ /** @type {Set<string>} */
142
+ const routeModuleSet = new Set();
143
+ for (const page of routeTable.pages || []) {
144
+ if (page.file) routeModuleSet.add(page.file);
145
+ for (const f of page.layouts || []) routeModuleSet.add(f);
146
+ }
147
+ const routeModules = [...routeModuleSet];
148
+
149
+ // error / loading / not-found modules ALSO ship to the browser, but unlike
150
+ // pages + layouts they are never elided: the dev server's
151
+ // `computeBrowserBoundFiles` adds them to the browser-bound entry set
152
+ // unconditionally (only ELIDABLE-COMPONENT imports are ever stripped, and
153
+ // these modules have no component to strip). So a personalized 404 that does
154
+ // `await auth()` is a real throw-at-load crash the page+layout-only candidate
155
+ // set would miss. Collect them here and add them to the candidate set as
156
+ // always-shipping (no elision verdict to consult).
157
+ /** @type {Map<string, string>} abs file -> kind */
158
+ const alwaysShipRouteModules = new Map();
159
+ for (const page of routeTable.pages || []) {
160
+ for (const f of page.errors || []) alwaysShipRouteModules.set(f, 'error boundary');
161
+ for (const f of page.loadings || []) alwaysShipRouteModules.set(f, 'loading boundary');
162
+ // forbidden / unauthorized joined this set with #1298. They used to render
163
+ // with an empty boot set, so their modules never reached the browser and
164
+ // omitting them here was harmless. They now render inside their layout
165
+ // chain AND ship, so a `forbidden.ts` doing `import { auth } from
166
+ // '#lib/auth.server.ts'` (a natural thing on a 403: "signed in as X, but
167
+ // not permitted") is a real throw-at-load crash that takes every sibling
168
+ // registration in the same module script with it.
169
+ for (const f of page.forbiddens || []) alwaysShipRouteModules.set(f, 'forbidden boundary');
170
+ for (const f of page.unauthorizeds || []) alwaysShipRouteModules.set(f, 'unauthorized boundary');
171
+ }
172
+ if (routeTable.notFound) alwaysShipRouteModules.set(routeTable.notFound, 'not-found page');
173
+ if (routeTable.notFounds) {
174
+ for (const f of routeTable.notFounds.values()) {
175
+ alwaysShipRouteModules.set(f, 'not-found page');
176
+ }
177
+ }
178
+ // global-not-found ships for the same reason: a thrown notFound() with no
179
+ // nearer boundary renders it against the matched route, so it wraps and
180
+ // boots. global-ERROR is deliberately absent: it returns its own document
181
+ // verbatim with no importmap and no boot script, so nothing of it ever
182
+ // reaches the browser.
183
+ if (routeTable.globalNotFound) {
184
+ alwaysShipRouteModules.set(routeTable.globalNotFound, 'global-not-found page');
185
+ }
186
+
187
+ // The elision flag mirrors `dev.js`: respect `webjs.elide === false` and the
188
+ // WEBJS_ELIDE override. When elision is OFF, the build ships EVERY component
189
+ // and route module, so the verdict is "nothing is elidable / inert" and the
190
+ // rule treats every candidate as shipping (which is correct: with elision
191
+ // off, a display-only page really does ship its server import too).
192
+ const elideEnabled = await readElideEnabledForCheck(appDir);
193
+ const { elidableComponents, inertRouteModules, importOnlyRouteModules } = elideEnabled
194
+ ? await analyzeElision(components, routeModules, moduleGraph, (f) => readFile(f, 'utf8'), appDir)
195
+ : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
196
+
197
+ // Candidate browser-shipped modules: components that are NOT elided, plus
198
+ // route modules that are NOT inert and NOT import-only (an import-only
199
+ // module is dropped from the boot in favour of its component frontier,
200
+ // #605/#963, so its own imports never load in a browser and a bare
201
+ // server-only import in it is harmless). A `.server.*` file is never a
202
+ // component (the scanner skips it) nor a route module the browser loads, so
203
+ // it cannot enter this set; server-to-server imports are excluded by
204
+ // construction.
205
+ /** @type {Map<string, { kind: string }>} relFile is keyed by ABS path */
206
+ const candidates = new Map();
207
+ for (const c of components) {
208
+ if (!elidableComponents.has(c.file)) candidates.set(c.file, { kind: 'component' });
209
+ }
210
+ for (const file of routeModules) {
211
+ if (inertRouteModules.has(file) || importOnlyRouteModules.has(file)) continue;
212
+ const base = basename(file);
213
+ const kind = /^layout\./.test(base) ? 'layout' : 'page';
214
+ candidates.set(file, { kind });
215
+ }
216
+ // error / loading / not-found modules always ship (never elided), so they are
217
+ // candidates unconditionally. A page/layout entry already in `candidates`
218
+ // wins (it is the more specific kind); these only add files not already seen.
219
+ for (const [file, kind] of alwaysShipRouteModules) {
220
+ if (!candidates.has(file)) candidates.set(file, { kind });
221
+ }
222
+
223
+ // Report at most once per module (a page importing two server modules is one
224
+ // finding, naming the first reached). Sorted for deterministic output.
225
+ for (const file of [...candidates.keys()].sort()) {
226
+ // `transitiveDeps` skips nothing here, so it includes (but does not walk
227
+ // into) any `.server.*` file reachable from this module. The module itself
228
+ // is not in the result. A direct OR indirect server import both surface,
229
+ // because the closure walks every non-server edge until it hits one.
230
+ const closure = transitiveDeps(moduleGraph, [file], appDir);
231
+ // Of the reachable server files, find one that is a genuine throw-at-load
232
+ // crash in the browser. TWO kinds of `.server.*` import are NOT crashes and
233
+ // must be skipped, or the rule false-positives on legitimate code:
234
+ // - A `'use server'` ACTION. The browser receives a working RPC stub
235
+ // whose exports POST to the server, so calling it from a shipping
236
+ // module is the intended pattern (the issue even lists it as a fix).
237
+ // Only a bare `.server.*` utility (no directive) gets the
238
+ // throw-at-module-load stub that crashes the page.
239
+ // - A PHANTOM edge to a file that does not exist on disk. The module
240
+ // graph keeps quoted-string CONTENT verbatim, so an `import` written
241
+ // inside a code-example string (the docs / website `<pre>` samples)
242
+ // resolves to a non-existent path. That import never runs, so it is
243
+ // not a crash; require the server file to actually exist.
244
+ let serverDep = null;
245
+ for (const d of closure) {
246
+ if (!/\.server\.m?[jt]s$/.test(d)) continue;
247
+ if (await isUseServerActionFile(d)) continue; // working RPC stub, not a crash
248
+ if (!(await pathExists(d))) continue; // phantom edge from a string sample
249
+ serverDep = d;
250
+ break;
251
+ }
252
+ if (!serverDep) continue;
253
+
254
+ const { kind } = candidates.get(file);
255
+ const relFile = relative(appDir, file);
256
+ const relServer = relative(appDir, serverDep);
257
+ // Name the import chain: if the server file is a DIRECT import of this
258
+ // module, the chain is just the two; otherwise show one intermediate hop
259
+ // so the diagnostic points at where the edge enters (the full path is
260
+ // recoverable from the graph, but one hop is enough to locate it).
261
+ const chainFiles = findImportChain(moduleGraph, file, serverDep);
262
+ const chain = chainFiles.map((f) => relative(appDir, f)).join(' -> ');
263
+ // If the edge into the server file comes from a types-shaped module, the
264
+ // idiomatic fix is to relocate that type to a browser-safe typedef (#804).
265
+ const importer = chainFiles.length >= 2 ? chainFiles[chainFiles.length - 2] : null;
266
+ const viaTypesModule = importer && /(^|\/)types(\.m?[jt]s$|\/)/.test(relative(appDir, importer));
267
+
268
+ // The "elides again" remedy only applies to a page / layout: since the
269
+ // path-aware import-only verdict (#963), a page importing a component to
270
+ // register is dropped from the boot, so a page/layout on this rule ships
271
+ // because of its OWN client work (or a client-effecting non-component in
272
+ // its closure); moving that work into a component makes it a dropped
273
+ // carrier again and the server import never loads. The error / loading /
274
+ // not-found boundaries always ship and are never elided, so offering
275
+ // them an "elides again" fix is wrong. Branch the fix text on kind.
276
+ const canElide = kind === 'page' || kind === 'layout';
277
+ const typesHint = viaTypesModule
278
+ ? `The edge enters via a types-shaped module (${relative(appDir, importer)}); if it re-exports a runtime VALUE from a \`.server.{ts,js}\` file, relocate that to a browser-safe typedef (a plain \`interface\` / JSDoc, or an \`import type\` which the stripper erases) so the type is shared without pinning the module. `
279
+ : '';
280
+ const fixText = canElide
281
+ ? `${typesHint}Keep the server call off this browser-shipped ${kind}. Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC; or (3) move this ${kind}'s own client work (the module-scope call, browser-global access, or client-effecting util import that pins it) into a component, so the ${kind} elides again as a dropped carrier and its server import never loads.`
282
+ : `${typesHint}Keep the server call off this browser-shipped ${kind} (it always ships and is never elided). Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); or (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC.`;
283
+
284
+ violations.push({
285
+ rule: 'no-server-import-in-browser-module',
286
+ file: relFile,
287
+ message:
288
+ `This ${kind} ships to the browser (the build does not elide it) but transitively imports the server-only module ${relServer} (${chain}). In the browser that import resolves to a stub, so the module crashes at load (the stub throws, or a server-only export such as \`auth\` is missing). \`webjs typecheck\` and the rest of \`webjs check\` pass; only the running ${kind} fails.`,
289
+ fix: fixText,
290
+ });
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Read whether component elision is enabled for `appDir`, mirroring
296
+ * `dev.js`'s `readElideEnabled` so the check's notion of "ships" matches the
297
+ * dev server's. Elision is ON unless `webjs.elide === false` in package.json or
298
+ * the `WEBJS_ELIDE` env var forces it off (`0` / `false` / `off` / `no`). A
299
+ * missing or malformed package.json keeps the default (on). Inlined rather
300
+ * than imported from `dev.js` so the check tool does not pull the whole dev
301
+ * server module just for this flag.
302
+ *
303
+ * @param {string} appDir
304
+ * @returns {Promise<boolean>}
305
+ */
306
+ /**
307
+ * True if `file` is a `'use server'` action: a `.server.{ts,js}` module that
308
+ * declares the `'use server'` directive. The dev server rewrites its browser
309
+ * import into a working RPC stub (exports POST to the server), so importing it
310
+ * from a shipping module is legitimate, NOT the throw-at-load crash the
311
+ * no-server-import-in-browser-module rule catches. A bare `.server.*` utility
312
+ * (no directive) instead gets a stub that throws when the module loads, which
313
+ * IS the crash. Returns false on any read failure (treat an unreadable server
314
+ * file as a potential crash, the conservative direction for this rule).
315
+ *
316
+ * @param {string} file absolute path to a `.server.*` file
317
+ * @returns {Promise<boolean>}
318
+ */
319
+ export async function isUseServerActionFile(file) {
320
+ try {
321
+ const content = await readFile(file, 'utf8');
322
+ return hasUseServerDirective(content);
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+
328
+ export async function readElideEnabledForCheck(appDir) {
329
+ const raw = process.env.WEBJS_ELIDE;
330
+ if (raw != null) {
331
+ const v = raw.trim().toLowerCase();
332
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
333
+ if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
334
+ }
335
+ try {
336
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
337
+ if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
338
+ } catch {
339
+ // No package.json, malformed JSON, or unreadable: keep the default.
340
+ }
341
+ return true;
342
+ }
343
+
344
+ /**
345
+ * Async fs.exists shim. Returns true if the path exists at all (file
346
+ * or directory), false on ENOENT or any other stat failure.
347
+ *
348
+ * @param {string} p absolute path
349
+ * @returns {Promise<boolean>}
350
+ */
351
+ export async function pathExists(p) {
352
+ try {
353
+ await stat(p);
354
+ return true;
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * The subset of `rels` (appDir-relative paths) that git reports as ignored,
362
+ * via a single batched `git check-ignore --stdin`. Best-effort: returns an
363
+ * empty Set when the directory is not a git repo, git is absent, or the
364
+ * spawn fails, so a non-git project scans every file as before. Runs with
365
+ * `cwd: appDir` and the inherited GIT_* env stripped so cwd is the sole
366
+ * authority on which repo + .gitignore stack is consulted (a pre-commit
367
+ * hook from a linked worktree exports GIT_WORK_TREE, which would otherwise
368
+ * override cwd-based discovery; same reason the doctor vendor-gitignore check
369
+ * strips them).
370
+ * Works for an in-repo sub-package with no nested `.git` too: git walks up
371
+ * to the monorepo root and resolves the relative paths against cwd.
372
+ *
373
+ * @param {string} appDir absolute app directory
374
+ * @param {string[]} rels appDir-relative file paths
375
+ * @returns {Promise<Set<string>>}
376
+ */
377
+ export async function gitIgnoredSet(appDir, rels) {
378
+ /** @type {Set<string>} */
379
+ const out = new Set();
380
+ if (!rels.length) return out;
381
+ try {
382
+ const { spawnSync } = await import('node:child_process');
383
+ const {
384
+ GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
385
+ ...gitEnv
386
+ } = process.env;
387
+ // `git check-ignore --stdin` exits 0 when ≥1 path is ignored (those
388
+ // paths are echoed on stdout), 1 when none are ignored, >1 on error.
389
+ const res = spawnSync('git', ['check-ignore', '--stdin'], {
390
+ cwd: appDir,
391
+ input: rels.join('\n'),
392
+ encoding: 'utf8',
393
+ env: gitEnv,
394
+ });
395
+ if (res.status === 0 && typeof res.stdout === 'string') {
396
+ for (const line of res.stdout.split('\n')) {
397
+ const p = line.trim();
398
+ if (p) out.add(p);
399
+ }
400
+ }
401
+ } catch {
402
+ // git missing or spawn failure: scan everything (no filter).
403
+ }
404
+ return out;
405
+ }
@@ -0,0 +1,113 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { relative } from 'node:path';
3
+ import { walk } from '../fs-walk.js';
4
+ import {
5
+ redactStringsAndTemplates,
6
+ } from '../js-scan.js';
7
+ import {
8
+ checkComponentsHaveRegister,
9
+ checkNoStaticProperties,
10
+ checkReactivePropsNoClassField,
11
+ checkArrayPropUsesArrayType,
12
+ checkNoBrowserGlobalsInRender,
13
+ checkNoShadowedNativeMember,
14
+ } from './rules-components.js';
15
+ import {
16
+ checkNoRedirectInApiRoute,
17
+ checkNoInterpolationInRawTextElement,
18
+ checkNoServerEnvInComponents,
19
+ checkShellInNonRootLayout,
20
+ } from './rules-routing.js';
21
+ import {
22
+ checkErasableTypescriptOnly,
23
+ checkNoNonErasableTypescript,
24
+ } from './rules-typescript.js';
25
+ import {
26
+ checkUseServerNeedsExtension,
27
+ checkUseServerExportsCallable,
28
+ checkOneActionPerConfiguredFile,
29
+ } from './rules-actions.js';
30
+ import {
31
+ checkTagNameHasHyphen,
32
+ checkNoDuplicateTag,
33
+ } from './rules-registry.js';
34
+ import { checkServerImportInBrowserModule } from './runner-support.js';
35
+ import {
36
+ checkNoMissingLocalImport,
37
+ checkFormActionNotAGetAction,
38
+ } from './rules-imports.js';
39
+
40
+ /**
41
+ * @typedef {import('./rules.js').Violation} Violation
42
+ */
43
+
44
+ /**
45
+ * Scan a WebJs app directory and report convention violations.
46
+ *
47
+ * Every rule is a correctness check (a crash, a security leak, a reactive
48
+ * prop that silently stops re-rendering, or a build/type-strip failure), so
49
+ * they all run unconditionally. There is no
50
+ * per-project disabling: project conventions (layout, style, process) live in
51
+ * CONVENTIONS.md as guidance, not in this tool.
52
+ *
53
+ * @param {string} appDir absolute path to the app root (the directory
54
+ * containing `app/`, `modules/`, `components/`, etc.)
55
+ * @returns {Promise<Violation[]>}
56
+ *
57
+ * @example
58
+ * ```js
59
+ * import { checkConventions } from '@webjsdev/server';
60
+ * const violations = await checkConventions('/path/to/myapp');
61
+ * for (const v of violations) {
62
+ * console.warn(`[${v.rule}] ${v.file}: ${v.message}`);
63
+ * }
64
+ * ```
65
+ */
66
+ export async function checkConventions(appDir) {
67
+ /** @type {Violation[]} */
68
+ const violations = [];
69
+
70
+ // Collect all JS/TS files in the app directory. Each entry carries
71
+ // both the raw `content` (for rules that need verbatim source: the
72
+ // `'use server'` directive detector, the `.gitignore` reader, etc.)
73
+ // and a `scan` view with comments, string contents, and
74
+ // template-literal bodies redacted to whitespace. Rules that
75
+ // pattern-match across raw source should consume `scan` so docs-
76
+ // page code-block examples and JSDoc samples don't trigger false
77
+ // positives.
78
+ /** @type {{ abs: string, rel: string, content: string, scan: string }[]} */
79
+ const files = [];
80
+ for await (const abs of walk(appDir, (p) => /\.m?[jt]sx?$/.test(p))) {
81
+ const rel = relative(appDir, abs);
82
+ let content;
83
+ try {
84
+ content = await readFile(abs, 'utf8');
85
+ } catch {
86
+ continue;
87
+ }
88
+ files.push({ abs, rel, content, scan: redactStringsAndTemplates(content) });
89
+ }
90
+
91
+ checkComponentsHaveRegister(files, violations);
92
+ checkNoStaticProperties(files, violations);
93
+ checkReactivePropsNoClassField(files, violations);
94
+ checkArrayPropUsesArrayType(files, violations);
95
+ checkNoBrowserGlobalsInRender(files, violations);
96
+ checkNoShadowedNativeMember(files, violations);
97
+ checkNoRedirectInApiRoute(files, violations);
98
+ checkNoInterpolationInRawTextElement(files, violations);
99
+ checkNoServerEnvInComponents(files, violations);
100
+ checkShellInNonRootLayout(files, violations);
101
+ await checkErasableTypescriptOnly(appDir, violations);
102
+ await checkNoNonErasableTypescript(appDir, violations);
103
+ checkUseServerNeedsExtension(files, violations);
104
+ checkUseServerExportsCallable(files, violations);
105
+ checkOneActionPerConfiguredFile(files, violations);
106
+ checkTagNameHasHyphen(files, violations);
107
+ await checkNoDuplicateTag(appDir, files, violations);
108
+ await checkServerImportInBrowserModule(appDir, violations);
109
+ checkNoMissingLocalImport(appDir, files, violations);
110
+ checkFormActionNotAGetAction(appDir, files, violations);
111
+
112
+ return violations;
113
+ }