@webjsdev/server 0.8.8 → 0.8.10
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.js +3 -0
- package/package.json +1 -1
- package/src/actions.js +37 -5
- package/src/api.js +16 -1
- package/src/auth.js +18 -3
- package/src/body-limit.js +291 -0
- package/src/check.js +41 -350
- package/src/context.js +66 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +215 -10
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/ssr.js +41 -23
package/src/check.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep, basename, dirname } from 'node:path';
|
|
3
3
|
import { walk } from './fs-walk.js';
|
|
4
4
|
import {
|
|
@@ -10,18 +10,17 @@ import {
|
|
|
10
10
|
/**
|
|
11
11
|
* Convention validator for webjs apps.
|
|
12
12
|
*
|
|
13
|
-
* Scans an app directory and reports
|
|
14
|
-
*
|
|
15
|
-
* or `webjs
|
|
13
|
+
* Scans an app directory and reports correctness violations: things that
|
|
14
|
+
* crash the app, leak a secret, or fail the build / type-strip. Designed to be
|
|
15
|
+
* run by AI agents, CI pipelines, or `webjs check` to catch real breakage
|
|
16
|
+
* early. Every rule is unconditional (no per-project disabling): project
|
|
17
|
+
* conventions (layout, style, process) are guidance in CONVENTIONS.md, not
|
|
18
|
+
* rules in this tool.
|
|
16
19
|
*
|
|
17
20
|
* **How AI agents should use the output:**
|
|
18
21
|
* Each violation includes a machine-readable `rule` identifier, the offending
|
|
19
22
|
* `file` (relative to appDir), a human-readable `message`, and a suggested
|
|
20
23
|
* `fix`. Agents should iterate the array and apply (or propose) the fixes.
|
|
21
|
-
* Rules can be disabled per-project via the
|
|
22
|
-
* `"webjs": { "conventions": { … } }` key in `package.json`. That is
|
|
23
|
-
* the only supported config surface. If the key is absent, every
|
|
24
|
-
* rule defaults to enabled.
|
|
25
24
|
*
|
|
26
25
|
* @module check
|
|
27
26
|
*/
|
|
@@ -49,16 +48,6 @@ import {
|
|
|
49
48
|
* @type {RuleDescriptor[]}
|
|
50
49
|
*/
|
|
51
50
|
export const RULES = [
|
|
52
|
-
{
|
|
53
|
-
name: 'actions-in-modules',
|
|
54
|
-
description:
|
|
55
|
-
'Server action files (*.server.{js,ts} or \'use server\') should live under modules/*/actions/ or modules/*/queries/, not loose in the app root. Files under lib/ are exempt: lib/ is the documented home for cross-cutting server infrastructure (prisma client, session helpers, auth config). Skipped when no modules/ directory exists.',
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: 'one-function-per-action',
|
|
59
|
-
description:
|
|
60
|
-
'Each .server.{js,ts} file under modules/*/actions/ or modules/*/queries/ should export exactly one async function (one-function-per-file convention). Files outside those two directories: lib/ infrastructure modules, route handlers: are exempt; this rule is specifically about the action/query file pattern.',
|
|
61
|
-
},
|
|
62
51
|
{
|
|
63
52
|
name: 'components-have-register',
|
|
64
53
|
description:
|
|
@@ -69,11 +58,6 @@ export const RULES = [
|
|
|
69
58
|
description:
|
|
70
59
|
'Component files (under components/ or modules/*/components/) must not read non-public environment variables. process.env.X is allowed when X starts with WEBJS_PUBLIC_ (exposed to the browser via the SSR shim) or equals NODE_ENV (also defined in the browser). Any other process.env read in a component would leak the server-side value into the SSR\'d HTML, then read as undefined after hydration. Read server-only env vars in a page function, server action, or middleware (which never reach the browser as source) and pass derived values to the component as attributes.',
|
|
71
60
|
},
|
|
72
|
-
{
|
|
73
|
-
name: 'tests-exist',
|
|
74
|
-
description:
|
|
75
|
-
'Each modules/<feature>/ directory should have corresponding test files under test/unit/ or test/e2e/.',
|
|
76
|
-
},
|
|
77
61
|
{
|
|
78
62
|
name: 'tag-name-has-hyphen',
|
|
79
63
|
description:
|
|
@@ -84,11 +68,6 @@ export const RULES = [
|
|
|
84
68
|
description:
|
|
85
69
|
'Reactive properties listed in `static properties = { … }` must be typed with `declare propName: Type` (no value), and have their default set in `constructor()`. Plain class-field initializers (`prop = value` or `prop: Type = value`) compile to Object.defineProperty *after* super() under modern class-field semantics, clobbering the framework\'s reactive accessor and silently breaking re-renders.',
|
|
86
70
|
},
|
|
87
|
-
{
|
|
88
|
-
name: 'no-json-data-files',
|
|
89
|
-
description:
|
|
90
|
-
'Apps must use Prisma + SQLite (already wired up in every scaffold) for persisted data, not JSON files. Flags JSON files that look like a fake database: top-level data/ JSON files (data/todos.json, data/posts.json…), or DB-shaped names (db.json, database.json, store.json, *-db.json) anywhere outside node_modules/, prisma/, .next/, dist/, build/, public/. Read-only seed data and config JSON (package.json, tsconfig.json, etc.) are exempt.',
|
|
91
|
-
},
|
|
92
71
|
{
|
|
93
72
|
name: 'shell-in-non-root-layout',
|
|
94
73
|
description:
|
|
@@ -117,7 +96,7 @@ export const RULES = [
|
|
|
117
96
|
{
|
|
118
97
|
name: 'no-browser-globals-in-render',
|
|
119
98
|
description:
|
|
120
|
-
'Flags browser-only APIs used in a WebComponent constructor or render() method. The SSR pipeline instantiates the component
|
|
99
|
+
'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
|
|
121
100
|
},
|
|
122
101
|
];
|
|
123
102
|
|
|
@@ -162,105 +141,6 @@ function isComponentFile(relPath) {
|
|
|
162
141
|
return segments.includes('components');
|
|
163
142
|
}
|
|
164
143
|
|
|
165
|
-
/**
|
|
166
|
-
* Public wrapper around `loadOverrides` for callers (CLI, docs tools)
|
|
167
|
-
* that want to inspect what's disabled in a project without running
|
|
168
|
-
* the full check pipeline.
|
|
169
|
-
*
|
|
170
|
-
* @param {string} appDir
|
|
171
|
-
* @returns {Promise<Record<string, boolean>>}
|
|
172
|
-
*/
|
|
173
|
-
export async function loadConventionOverrides(appDir) {
|
|
174
|
-
return loadOverrides(appDir);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Load overrides from the `"webjs": { "conventions": { … } }` key in
|
|
179
|
-
* `package.json`. Returns a map of rule name to boolean (true =
|
|
180
|
-
* enabled, false = disabled). Missing rules default to true.
|
|
181
|
-
*
|
|
182
|
-
* @param {string} appDir
|
|
183
|
-
* @returns {Promise<Record<string, boolean>>}
|
|
184
|
-
*/
|
|
185
|
-
async function loadOverrides(appDir) {
|
|
186
|
-
try {
|
|
187
|
-
const pkgPath = join(appDir, 'package.json');
|
|
188
|
-
const pkgText = await readFile(pkgPath, 'utf8');
|
|
189
|
-
const pkg = JSON.parse(pkgText);
|
|
190
|
-
if (pkg.webjs && typeof pkg.webjs === 'object'
|
|
191
|
-
&& pkg.webjs.conventions && typeof pkg.webjs.conventions === 'object') {
|
|
192
|
-
return pkg.webjs.conventions;
|
|
193
|
-
}
|
|
194
|
-
} catch {
|
|
195
|
-
// No package.json: every rule defaults to enabled.
|
|
196
|
-
}
|
|
197
|
-
return {};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Check whether a rule is enabled given the overrides.
|
|
202
|
-
* @param {string} ruleName
|
|
203
|
-
* @param {Record<string, boolean>} overrides
|
|
204
|
-
* @returns {boolean}
|
|
205
|
-
*/
|
|
206
|
-
function isRuleEnabled(ruleName, overrides) {
|
|
207
|
-
if (ruleName in overrides) return overrides[ruleName] !== false;
|
|
208
|
-
return true;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Guess a module name from a loose server action file path. Used for the
|
|
213
|
-
* `fix` suggestion in `actions-in-modules`.
|
|
214
|
-
* @param {string} relPath
|
|
215
|
-
* @returns {string}
|
|
216
|
-
*/
|
|
217
|
-
function guessModuleName(relPath) {
|
|
218
|
-
const segments = relPath.split(sep);
|
|
219
|
-
// Try to infer from the parent directory name
|
|
220
|
-
// e.g. app/api/users/create.server.ts -> "users"
|
|
221
|
-
for (let i = segments.length - 2; i >= 0; i--) {
|
|
222
|
-
const seg = segments[i];
|
|
223
|
-
if (seg !== 'app' && seg !== 'api' && !seg.startsWith('[') && !seg.startsWith('(') && !seg.startsWith('_')) {
|
|
224
|
-
return seg;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
// Fall back to the file stem
|
|
228
|
-
const base = basename(relPath).replace(/\.server\.m?[jt]s$/, '').replace(/\.m?[jt]s$/, '');
|
|
229
|
-
return base;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Count the number of named exported async functions in source text using
|
|
234
|
-
* regex heuristics (no AST: intentionally fast and loose).
|
|
235
|
-
*
|
|
236
|
-
* Looks for patterns like:
|
|
237
|
-
* export async function name(...)
|
|
238
|
-
* export const name = async (...)
|
|
239
|
-
* export const name = async function(...)
|
|
240
|
-
* export default async function(...)
|
|
241
|
-
*
|
|
242
|
-
* @param {string} content
|
|
243
|
-
* @returns {number}
|
|
244
|
-
*/
|
|
245
|
-
function countExportedFunctions(content) {
|
|
246
|
-
const patterns = [
|
|
247
|
-
/export\s+async\s+function\s+\w+/g,
|
|
248
|
-
/export\s+const\s+\w+\s*=\s*async\s/g,
|
|
249
|
-
/export\s+default\s+async\s+function/g,
|
|
250
|
-
/export\s+function\s+\w+/g,
|
|
251
|
-
/export\s+const\s+\w+\s*=\s*(?:async\s*)?\(/g,
|
|
252
|
-
/export\s+const\s+\w+\s*=\s*(?:async\s*)?function/g,
|
|
253
|
-
];
|
|
254
|
-
const seen = new Set();
|
|
255
|
-
for (const pat of patterns) {
|
|
256
|
-
let m;
|
|
257
|
-
while ((m = pat.exec(content)) !== null) {
|
|
258
|
-
seen.add(m.index);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return seen.size;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
144
|
/**
|
|
265
145
|
* Find every `<key>:` entry inside the first `static properties = { … }`
|
|
266
146
|
* literal in `classBody`. Returns the bare property names: the keys
|
|
@@ -402,11 +282,16 @@ const BROWSER_GLOBALS = [
|
|
|
402
282
|
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
403
283
|
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
404
284
|
];
|
|
405
|
-
// HTMLElement instance members that do not exist on the
|
|
406
|
-
// `this.<member>` throws (a method call) or is `undefined` (a property) at
|
|
285
|
+
// HTMLElement instance members that do not exist on the server element shim,
|
|
286
|
+
// so `this.<member>` throws (a method call) or is `undefined` (a property) at
|
|
287
|
+
// SSR. The attribute methods (get/set/has/remove/toggleAttribute), the event
|
|
288
|
+
// methods (add/removeEventListener, dispatchEvent), and attachInternals are
|
|
289
|
+
// backed by the shim and run server-side, so they are intentionally NOT
|
|
290
|
+
// flagged: a component may read attributes in render and reflect properties
|
|
291
|
+
// during the SSR update cycle. What stays is the genuinely browser-only
|
|
292
|
+
// surface (DOM querying, layout reads, shadow construction, focus).
|
|
407
293
|
const HTMLELEMENT_MEMBERS = [
|
|
408
|
-
'attachShadow', 'shadowRoot', '
|
|
409
|
-
'removeAttribute', 'hasAttribute', 'dispatchEvent', 'classList',
|
|
294
|
+
'attachShadow', 'shadowRoot', 'classList',
|
|
410
295
|
'querySelector', 'querySelectorAll', 'getBoundingClientRect',
|
|
411
296
|
'focus', 'blur', 'scrollIntoView',
|
|
412
297
|
];
|
|
@@ -463,12 +348,13 @@ function findBrowserMemberUses(code) {
|
|
|
463
348
|
/**
|
|
464
349
|
* Scan a webjs app directory and report convention violations.
|
|
465
350
|
*
|
|
466
|
-
*
|
|
351
|
+
* Every rule is a correctness check (a crash, a security leak, or a
|
|
352
|
+
* build/type-strip failure), so they all run unconditionally. There is no
|
|
353
|
+
* per-project disabling: project conventions (layout, style, process) live in
|
|
354
|
+
* CONVENTIONS.md as guidance, not in this tool.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} appDir absolute path to the app root (the directory
|
|
467
357
|
* containing `app/`, `modules/`, `components/`, etc.)
|
|
468
|
-
* @param {{ rules?: Record<string, boolean> }} [opts] - programmatic
|
|
469
|
-
* overrides. Merged on top of file-based overrides loaded from
|
|
470
|
-
* `package.json` `"webjs"."conventions"`. Set a rule to `false` to
|
|
471
|
-
* skip it.
|
|
472
358
|
* @returns {Promise<Violation[]>}
|
|
473
359
|
*
|
|
474
360
|
* @example
|
|
@@ -480,38 +366,10 @@ function findBrowserMemberUses(code) {
|
|
|
480
366
|
* }
|
|
481
367
|
* ```
|
|
482
368
|
*/
|
|
483
|
-
export async function checkConventions(appDir
|
|
484
|
-
const fileOverrides = await loadOverrides(appDir);
|
|
485
|
-
const overrides = { ...fileOverrides, ...(opts?.rules || {}) };
|
|
486
|
-
|
|
369
|
+
export async function checkConventions(appDir) {
|
|
487
370
|
/** @type {Violation[]} */
|
|
488
371
|
const violations = [];
|
|
489
372
|
|
|
490
|
-
// Determine if modules/ directory exists (small apps exempt from some rules)
|
|
491
|
-
let hasModulesDir = false;
|
|
492
|
-
try {
|
|
493
|
-
const s = await stat(join(appDir, 'modules'));
|
|
494
|
-
hasModulesDir = s.isDirectory();
|
|
495
|
-
} catch {
|
|
496
|
-
// no modules/ dir
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Determine which module feature names exist
|
|
500
|
-
/** @type {string[]} */
|
|
501
|
-
const moduleNames = [];
|
|
502
|
-
if (hasModulesDir) {
|
|
503
|
-
try {
|
|
504
|
-
const entries = await readdir(join(appDir, 'modules'), { withFileTypes: true });
|
|
505
|
-
for (const e of entries) {
|
|
506
|
-
if (e.isDirectory() && !e.name.startsWith('.')) {
|
|
507
|
-
moduleNames.push(e.name);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
} catch {
|
|
511
|
-
// could not read modules/
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
373
|
// Collect all JS/TS files in the app directory. Each entry carries
|
|
516
374
|
// both the raw `content` (for rules that need verbatim source: the
|
|
517
375
|
// `'use server'` directive detector, the `.gitignore` reader, etc.)
|
|
@@ -533,56 +391,8 @@ export async function checkConventions(appDir, opts) {
|
|
|
533
391
|
files.push({ abs, rel, content, scan: redactStringsAndTemplates(content) });
|
|
534
392
|
}
|
|
535
393
|
|
|
536
|
-
// --- Rule: actions-in-modules ---
|
|
537
|
-
if (hasModulesDir && isRuleEnabled('actions-in-modules', overrides)) {
|
|
538
|
-
for (const { abs, rel, content } of files) {
|
|
539
|
-
if (!isServerActionFile(abs, content)) continue;
|
|
540
|
-
const normRel = rel.split(sep).join('/');
|
|
541
|
-
// OK: action / query files inside modules/<feature>/{actions,queries}/
|
|
542
|
-
if (/^modules\/[^/]+\/(actions|queries)\//.test(normRel)) continue;
|
|
543
|
-
// OK: module-scoped components/utils (utils may use 'use server' too)
|
|
544
|
-
if (/^modules\/[^/]+\/(components|utils)\//.test(normRel)) continue;
|
|
545
|
-
// OK: cross-cutting server infrastructure under lib/. The documented
|
|
546
|
-
// pattern puts the Prisma singleton, session helpers, auth config,
|
|
547
|
-
// password hashing, etc. in lib/: those files are intentionally
|
|
548
|
-
// multi-export 'use server' modules, not one-function actions.
|
|
549
|
-
if (/^lib\//.test(normRel)) continue;
|
|
550
|
-
// Anything else (loose at the root, under app/, etc.) is flagged.
|
|
551
|
-
const moduleName = guessModuleName(rel);
|
|
552
|
-
const fileBase = basename(rel);
|
|
553
|
-
violations.push({
|
|
554
|
-
rule: 'actions-in-modules',
|
|
555
|
-
file: rel,
|
|
556
|
-
message: `Server action should be in modules/${moduleName}/actions/`,
|
|
557
|
-
fix: `Move to modules/${moduleName}/actions/${fileBase}`,
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// --- Rule: one-function-per-action ---
|
|
563
|
-
// Apply ONLY to files inside modules/<feature>/{actions,queries}/: that
|
|
564
|
-
// is where the one-function-per-file convention lives. lib/ infra modules
|
|
565
|
-
// and any other 'use server' file outside the action/query dirs are
|
|
566
|
-
// intentional multi-export utility modules and are exempt.
|
|
567
|
-
if (isRuleEnabled('one-function-per-action', overrides)) {
|
|
568
|
-
for (const { abs, rel, content } of files) {
|
|
569
|
-
if (!isServerActionFile(abs, content)) continue;
|
|
570
|
-
const normRel = rel.split(sep).join('/');
|
|
571
|
-
if (!/^modules\/[^/]+\/(actions|queries)\//.test(normRel)) continue;
|
|
572
|
-
const count = countExportedFunctions(content);
|
|
573
|
-
if (count > 1) {
|
|
574
|
-
violations.push({
|
|
575
|
-
rule: 'one-function-per-action',
|
|
576
|
-
file: rel,
|
|
577
|
-
message: `Server action file exports ${count} functions; convention is one per file`,
|
|
578
|
-
fix: 'Split into separate .server.{js,ts} files, one exported function each',
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
394
|
// --- Rule: components-have-register ---
|
|
585
|
-
|
|
395
|
+
{
|
|
586
396
|
for (const { rel, scan } of files) {
|
|
587
397
|
if (!isComponentFile(rel)) continue;
|
|
588
398
|
// Use redacted source so a code-example string like
|
|
@@ -606,7 +416,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
606
416
|
}
|
|
607
417
|
|
|
608
418
|
// --- Rule: reactive-props-use-declare ---
|
|
609
|
-
|
|
419
|
+
{
|
|
610
420
|
for (const { rel, scan } of files) {
|
|
611
421
|
// Use redacted source so test-fixture-style strings like
|
|
612
422
|
// `class X extends WebComponent { x = 0 }` inside template
|
|
@@ -629,15 +439,17 @@ export async function checkConventions(appDir, opts) {
|
|
|
629
439
|
}
|
|
630
440
|
|
|
631
441
|
// --- Rule: no-browser-globals-in-render ---
|
|
632
|
-
// The SSR pipeline runs the constructor (`new Cls()`) and
|
|
633
|
-
// on
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
442
|
+
// The SSR pipeline runs the constructor (`new Cls()`), willUpdate, and
|
|
443
|
+
// render() on the server element shim (attribute methods backed, but no real
|
|
444
|
+
// DOM). A genuinely browser-only global or an unshimmed HTMLElement member on
|
|
445
|
+
// `this` touched in any of those throws at SSR time. Those belong in
|
|
446
|
+
// connectedCallback / post-render hooks, which SSR never calls. willUpdate is
|
|
447
|
+
// scanned because it now runs at SSR (issue #217).
|
|
448
|
+
{
|
|
637
449
|
for (const { rel, scan } of files) {
|
|
638
450
|
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
639
451
|
for (const body of extractWebComponentClassBodies(scan)) {
|
|
640
|
-
for (const method of ['constructor', 'render']) {
|
|
452
|
+
for (const method of ['constructor', 'willUpdate', 'render']) {
|
|
641
453
|
const code = methodBodyOf(body, method);
|
|
642
454
|
if (!code) continue;
|
|
643
455
|
for (const { member, kind } of findBrowserMemberUses(code)) {
|
|
@@ -658,7 +470,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
658
470
|
// WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
|
|
659
471
|
// two categories to the browser; any other read either leaks a secret
|
|
660
472
|
// into the SSR'd HTML or reads as undefined after hydration.
|
|
661
|
-
|
|
473
|
+
{
|
|
662
474
|
for (const { abs, rel, content } of files) {
|
|
663
475
|
if (!isComponentFile(rel)) continue;
|
|
664
476
|
if (isServerActionFile(abs, content)) continue;
|
|
@@ -682,132 +494,11 @@ export async function checkConventions(appDir, opts) {
|
|
|
682
494
|
}
|
|
683
495
|
}
|
|
684
496
|
|
|
685
|
-
// --- Rule: tests-exist ---
|
|
686
|
-
if (hasModulesDir && isRuleEnabled('tests-exist', overrides)) {
|
|
687
|
-
for (const mod of moduleNames) {
|
|
688
|
-
// Look for test files that reference this module
|
|
689
|
-
let hasTest = false;
|
|
690
|
-
|
|
691
|
-
// Check test/unit/ and test/e2e/
|
|
692
|
-
for (const testDir of ['test/unit', 'test/e2e', 'test']) {
|
|
693
|
-
try {
|
|
694
|
-
const testDirAbs = join(appDir, testDir);
|
|
695
|
-
for await (const testFile of walk(testDirAbs, (p) => /\.(test|spec)\.m?[jt]sx?$/.test(p))) {
|
|
696
|
-
const testRel = relative(appDir, testFile);
|
|
697
|
-
// Check if test file name contains the module name
|
|
698
|
-
if (testRel.toLowerCase().includes(mod.toLowerCase())) {
|
|
699
|
-
hasTest = true;
|
|
700
|
-
break;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
} catch {
|
|
704
|
-
// test directory doesn't exist
|
|
705
|
-
}
|
|
706
|
-
if (hasTest) break;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
if (!hasTest) {
|
|
710
|
-
violations.push({
|
|
711
|
-
rule: 'tests-exist',
|
|
712
|
-
file: `modules/${mod}`,
|
|
713
|
-
message: `No test files found for module "${mod}"`,
|
|
714
|
-
fix: `Add test files under test/unit/${mod}.test.js or test/e2e/${mod}.test.js`,
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// --- Rule: no-json-data-files ---
|
|
721
|
-
// Catch AI agents (or hurried humans) using JSON files as a substitute for
|
|
722
|
-
// the real database. Every scaffold ships Prisma + SQLite ready to go, so
|
|
723
|
-
// there is never a good reason to invent `data/todos.json`, `db.json`,
|
|
724
|
-
// etc. The rule is intentionally narrow: we only flag JSON files that
|
|
725
|
-
// *look like* a database: by location (top-level `data/` directory) or by
|
|
726
|
-
// name (db/database/store/*-db). Config and read-only seed JSON elsewhere
|
|
727
|
-
// is left alone.
|
|
728
|
-
if (isRuleEnabled('no-json-data-files', overrides)) {
|
|
729
|
-
/** @type {Array<{rel: string, why: string}>} */
|
|
730
|
-
const suspects = [];
|
|
731
|
-
/**
|
|
732
|
-
* @param {string} dir absolute
|
|
733
|
-
* @param {string} relBase relative to appDir
|
|
734
|
-
*/
|
|
735
|
-
async function scanDir(dir, relBase) {
|
|
736
|
-
/** @type {import('node:fs').Dirent[]} */
|
|
737
|
-
let entries;
|
|
738
|
-
try {
|
|
739
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
740
|
-
} catch {
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
for (const e of entries) {
|
|
744
|
-
const name = e.name;
|
|
745
|
-
if (name.startsWith('.')) continue;
|
|
746
|
-
// Skip directories we know are not the user's data dir.
|
|
747
|
-
if (e.isDirectory()) {
|
|
748
|
-
if (
|
|
749
|
-
name === 'node_modules' ||
|
|
750
|
-
name === 'prisma' ||
|
|
751
|
-
name === 'dist' ||
|
|
752
|
-
name === 'build' ||
|
|
753
|
-
name === '.next' ||
|
|
754
|
-
name === 'coverage' ||
|
|
755
|
-
name === 'public'
|
|
756
|
-
) continue;
|
|
757
|
-
await scanDir(join(dir, name), relBase ? `${relBase}/${name}` : name);
|
|
758
|
-
continue;
|
|
759
|
-
}
|
|
760
|
-
if (!e.isFile()) continue;
|
|
761
|
-
if (!name.endsWith('.json')) continue;
|
|
762
|
-
const rel = relBase ? `${relBase}/${name}` : name;
|
|
763
|
-
|
|
764
|
-
// Skip well-known config / tooling JSON.
|
|
765
|
-
const configNames = new Set([
|
|
766
|
-
'package.json', 'package-lock.json', 'tsconfig.json',
|
|
767
|
-
'jsconfig.json', 'manifest.json', 'site.webmanifest',
|
|
768
|
-
'.eslintrc.json', '.prettierrc.json', 'compose.json',
|
|
769
|
-
'turbo.json', 'lerna.json', 'nx.json', 'biome.json',
|
|
770
|
-
'renovate.json', 'vercel.json', 'now.json', 'fly.json',
|
|
771
|
-
]);
|
|
772
|
-
if (configNames.has(name)) continue;
|
|
773
|
-
|
|
774
|
-
// Trigger 1: any JSON under a top-level `data/` directory.
|
|
775
|
-
if (rel.startsWith('data/')) {
|
|
776
|
-
suspects.push({ rel, why: `JSON file in top-level data/ directory (likely a fake database)` });
|
|
777
|
-
continue;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// Trigger 2: file name looks like a database.
|
|
781
|
-
const lower = name.toLowerCase();
|
|
782
|
-
const dbShapedName =
|
|
783
|
-
lower === 'db.json' ||
|
|
784
|
-
lower === 'database.json' ||
|
|
785
|
-
lower === 'store.json' ||
|
|
786
|
-
lower === 'storage.json' ||
|
|
787
|
-
/-db\.json$/.test(lower) ||
|
|
788
|
-
/\.db\.json$/.test(lower);
|
|
789
|
-
if (dbShapedName) {
|
|
790
|
-
suspects.push({ rel, why: `file name "${name}" suggests it is being used as a database` });
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
await scanDir(appDir, '');
|
|
795
|
-
|
|
796
|
-
for (const s of suspects) {
|
|
797
|
-
violations.push({
|
|
798
|
-
rule: 'no-json-data-files',
|
|
799
|
-
file: s.rel,
|
|
800
|
-
message: `${s.why}. webjs apps must persist data with Prisma + SQLite (already wired up: see prisma/schema.prisma and lib/prisma.server.ts), not JSON files.`,
|
|
801
|
-
fix: `Define a Prisma model in prisma/schema.prisma for this data, run \`webjs db migrate <name>\` to create the migration, then read/write via \`import { prisma } from 'lib/prisma.server.ts'\`. Delete ${s.rel} once the data has moved.`,
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
497
|
// --- Rule: shell-in-non-root-layout ---
|
|
807
498
|
// Only app/layout.{js,ts} may write <!doctype>/<html>/<head>/<body>. The
|
|
808
499
|
// framework auto-emits the shell around the whole composition; a nested
|
|
809
500
|
// shell ends up duplicated and silently dropped by the HTML parser.
|
|
810
|
-
|
|
501
|
+
{
|
|
811
502
|
// Root layout = exactly "app/layout.js" or "app/layout.ts".
|
|
812
503
|
const ROOT_LAYOUT = /^app\/layout\.(?:js|mjs|ts|mts)$/;
|
|
813
504
|
// Any other layout or page under app/ (including pages, nested layouts).
|
|
@@ -851,7 +542,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
851
542
|
// the editor before they ever hit the dev server. The companion
|
|
852
543
|
// no-non-erasable-typescript rule (below) catches violations even if
|
|
853
544
|
// the tsconfig flag is unset.
|
|
854
|
-
|
|
545
|
+
{
|
|
855
546
|
let tsconfigContent = null;
|
|
856
547
|
try {
|
|
857
548
|
tsconfigContent = await readFile(join(appDir, 'tsconfig.json'), 'utf8');
|
|
@@ -894,7 +585,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
894
585
|
// case where the flag is missing OR the user has bypassed it and
|
|
895
586
|
// written offending syntax anyway. Both rules ship enabled by
|
|
896
587
|
// default so violators get the strongest signal possible.
|
|
897
|
-
|
|
588
|
+
{
|
|
898
589
|
/** @type {Array<{ name: string, regex: RegExp, fix: string }>} */
|
|
899
590
|
const NON_ERASABLE_PATTERNS = [
|
|
900
591
|
{
|
|
@@ -985,7 +676,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
985
676
|
// directive alone does nothing (the file is served to the browser as
|
|
986
677
|
// plain source and exports are not registered as RPC), which is a
|
|
987
678
|
// silent footgun. The fix is mechanical: rename the file.
|
|
988
|
-
|
|
679
|
+
{
|
|
989
680
|
for (const { rel, content } of files) {
|
|
990
681
|
if (!hasUseServerDirective(content)) continue;
|
|
991
682
|
if (/\.server\.m?[jt]s$/.test(rel)) continue; // OK: has both markers
|
|
@@ -1002,7 +693,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
1002
693
|
}
|
|
1003
694
|
|
|
1004
695
|
// --- Rule: tag-name-has-hyphen ---
|
|
1005
|
-
|
|
696
|
+
{
|
|
1006
697
|
for (const { rel, scan } of files) {
|
|
1007
698
|
if (!isComponentFile(rel)) continue;
|
|
1008
699
|
// Use redacted source. A `register('tag')` call inside a
|
|
@@ -1048,7 +739,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
1048
739
|
//
|
|
1049
740
|
// Skipped when the directory isn't a git repo or has no .gitignore
|
|
1050
741
|
// (the user hasn't opted into version control yet).
|
|
1051
|
-
|
|
742
|
+
{
|
|
1052
743
|
const hasGit = await pathExists(join(appDir, '.git'));
|
|
1053
744
|
const hasGitignore = await pathExists(join(appDir, '.gitignore'));
|
|
1054
745
|
if (hasGit && hasGitignore) {
|
package/src/context.js
CHANGED
|
@@ -10,7 +10,17 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
|
10
10
|
*
|
|
11
11
|
* Strictly server-side: importing this module on the client is a bug.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* `cspNonce` holds the per-request CSP nonce when CSP is enabled
|
|
14
|
+
* (issue #233). It is minted in the request handler and written here via
|
|
15
|
+
* `setCspNonce`, so the same value the `Content-Security-Policy` header
|
|
16
|
+
* carries is what `cspNonce()` returns for the inline boot script.
|
|
17
|
+
*
|
|
18
|
+
* `bodyLimits` holds the resolved request body-size caps (issue #237) so
|
|
19
|
+
* `readBody` (used inside `route.{js,ts}` handlers, which have no handle to the
|
|
20
|
+
* server state) can enforce the same limit the RPC and page-action paths do. The
|
|
21
|
+
* handler writes it per request via `setBodyLimits`.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {{ req: Request, cspNonce?: string, bodyLimits?: { json: number, multipart: number } }} Store
|
|
14
24
|
*/
|
|
15
25
|
|
|
16
26
|
/** @type {AsyncLocalStorage<Store>} */
|
|
@@ -33,10 +43,52 @@ export function getRequest() {
|
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
46
|
+
* Set the per-request CSP nonce on the current AsyncLocalStorage store.
|
|
47
|
+
* Called by the request handler when CSP is enabled, AFTER it mints the
|
|
48
|
+
* nonce and BEFORE the page renders, so `cspNonce()` returns this exact
|
|
49
|
+
* value during SSR (the same value the response's
|
|
50
|
+
* `Content-Security-Policy` header carries: one source, no drift).
|
|
51
|
+
*
|
|
52
|
+
* A no-op outside a request scope, or when CSP is disabled (the handler
|
|
53
|
+
* simply never calls it, so the store's `cspNonce` stays undefined and
|
|
54
|
+
* `cspNonce()` falls through to '').
|
|
55
|
+
*
|
|
56
|
+
* @param {string} nonce
|
|
57
|
+
*/
|
|
58
|
+
export function setCspNonce(nonce) {
|
|
59
|
+
const store = als.getStore();
|
|
60
|
+
if (store) store.cspNonce = nonce;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Set the per-request resolved body-size limits on the current store (issue
|
|
65
|
+
* #237). The handler computes them once at boot (`readBodyLimits`) and stamps
|
|
66
|
+
* them on every request so `readBody` (json.js), which runs inside route
|
|
67
|
+
* handlers with no access to the server state, can enforce the same cap.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ json: number, multipart: number }} limits
|
|
70
|
+
*/
|
|
71
|
+
export function setBodyLimits(limits) {
|
|
72
|
+
const store = als.getStore();
|
|
73
|
+
if (store) store.bodyLimits = limits;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the per-request body-size limits, or null outside a request scope.
|
|
78
|
+
* @returns {{ json: number, multipart: number } | null}
|
|
79
|
+
*/
|
|
80
|
+
export function getBodyLimits() {
|
|
81
|
+
return als.getStore()?.bodyLimits ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Server-only implementation of the CSP nonce reader. Returns the
|
|
86
|
+
* per-request nonce that the handler MINTED and stored (issue #233) when
|
|
87
|
+
* CSP is enabled. Falls back to parsing an INBOUND
|
|
88
|
+
* `Content-Security-Policy` request header (the legacy consume-only
|
|
89
|
+
* behaviour) so an app sitting behind a proxy that already mints a nonce
|
|
90
|
+
* still works without enabling webjs's own CSP. Returns '' when neither
|
|
91
|
+
* is in scope.
|
|
40
92
|
*
|
|
41
93
|
* The public `cspNonce()` function lives in `@webjsdev/core` so user
|
|
42
94
|
* layouts / pages can import it without dragging server-only deps
|
|
@@ -46,18 +98,16 @@ export function getRequest() {
|
|
|
46
98
|
* `cspNonce()` returns '' (empty `nonce=""` attribute, browser
|
|
47
99
|
* ignores it).
|
|
48
100
|
*/
|
|
49
|
-
// The regex captures the first `nonce-...` token anywhere in the
|
|
50
|
-
// header. Webjs uses a single per-request nonce shared across
|
|
51
|
-
// directives that emit it (the standard CSP3 single-nonce model),
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// would need to become directive-scoped. Kept identical to the
|
|
55
|
-
// matching helper in ssr.js so both paths interpret the header the
|
|
56
|
-
// same way.
|
|
101
|
+
// The regex fallback captures the first `nonce-...` token anywhere in the
|
|
102
|
+
// inbound CSP header. Webjs uses a single per-request nonce shared across
|
|
103
|
+
// all directives that emit it (the standard CSP3 single-nonce model), so
|
|
104
|
+
// reading the first match is correct. Kept identical to the matching
|
|
105
|
+
// helper in ssr.js so both paths interpret the header the same way.
|
|
57
106
|
setCspNonceProvider(() => {
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
60
|
-
|
|
107
|
+
const store = als.getStore();
|
|
108
|
+
if (!store) return '';
|
|
109
|
+
if (typeof store.cspNonce === 'string') return store.cspNonce;
|
|
110
|
+
const csp = store.req?.headers.get('content-security-policy') || '';
|
|
61
111
|
const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
|
|
62
112
|
return match ? match[1] : '';
|
|
63
113
|
});
|