@webjsdev/server 0.7.2 → 0.8.0
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/README.md +10 -5
- package/index.js +21 -3
- package/package.json +4 -6
- package/src/actions.js +6 -6
- package/src/auth.js +1 -1
- package/src/cache.js +19 -2
- package/src/check.js +226 -95
- package/src/component-elision.js +797 -0
- package/src/component-scanner.js +8 -2
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +479 -94
- package/src/importmap.js +282 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +150 -13
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1231 -103
- package/src/websocket.js +3 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @webjsdev/server
|
|
2
2
|
|
|
3
|
-
Dev + production server for [webjs](https://github.com/
|
|
3
|
+
Dev + production server for [webjs](https://github.com/webjsdev/webjs):
|
|
4
4
|
file-based routing, streaming SSR, server actions, WebSocket upgrades, and
|
|
5
5
|
live reload.
|
|
6
6
|
|
|
@@ -18,8 +18,13 @@ to scaffold and run an app, which pulls this package in as a dependency.
|
|
|
18
18
|
- **WebSockets**: export `WS` from `route.ts` and it becomes a WebSocket
|
|
19
19
|
endpoint on the same path.
|
|
20
20
|
- **Live reload** for dev.
|
|
21
|
-
- **Bare-specifier
|
|
22
|
-
|
|
21
|
+
- **Bare-specifier resolution** for npm packages via import maps,
|
|
22
|
+
resolved through jspm.io at runtime (Rails 7 + importmap-rails
|
|
23
|
+
posture). Browser fetches bundles directly from `ga.jspm.io` CDN;
|
|
24
|
+
webjs's server does not bundle vendor packages. Run `webjs vendor
|
|
25
|
+
pin` to commit resolved URLs to `.webjs/vendor/importmap.json`
|
|
26
|
+
(deterministic deploys), or `--download` to additionally vendor
|
|
27
|
+
bundle bytes for offline-capable production.
|
|
23
28
|
|
|
24
29
|
## Install
|
|
25
30
|
|
|
@@ -41,10 +46,10 @@ Or programmatically:
|
|
|
41
46
|
```js
|
|
42
47
|
import { startServer } from '@webjsdev/server';
|
|
43
48
|
|
|
44
|
-
await startServer({ port:
|
|
49
|
+
await startServer({ port: 8080, appDir: process.cwd(), dev: true });
|
|
45
50
|
```
|
|
46
51
|
|
|
47
|
-
See the full framework docs at https://github.com/
|
|
52
|
+
See the full framework docs at https://github.com/webjsdev/webjs.
|
|
48
53
|
|
|
49
54
|
## License
|
|
50
55
|
|
package/index.js
CHANGED
|
@@ -11,12 +11,30 @@ export {
|
|
|
11
11
|
invokeAction,
|
|
12
12
|
} from './src/actions.js';
|
|
13
13
|
export { buildImportMap, importMapTag, setVendorEntries } from './src/importmap.js';
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
scanBareImports,
|
|
16
|
+
extractPackageName,
|
|
17
|
+
vendorImportMapEntries,
|
|
18
|
+
resolveVendorImports,
|
|
19
|
+
clearVendorCache,
|
|
20
|
+
getPackageVersion,
|
|
21
|
+
jspmGenerate,
|
|
22
|
+
pinAll,
|
|
23
|
+
unpinPackage,
|
|
24
|
+
listPinned,
|
|
25
|
+
auditPinned,
|
|
26
|
+
findOutdated,
|
|
27
|
+
updatePinned,
|
|
28
|
+
readPinFile,
|
|
29
|
+
serveDownloadedBundle,
|
|
30
|
+
SUPPORTED_PROVIDERS,
|
|
31
|
+
normalizeProvider,
|
|
32
|
+
} from './src/vendor.js';
|
|
15
33
|
export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
|
|
16
34
|
export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
|
|
17
|
-
export { headers, cookies, getRequest, withRequest } from './src/context.js';
|
|
35
|
+
export { headers, cookies, getRequest, withRequest, cspNonce } from './src/context.js';
|
|
18
36
|
export { defaultLogger } from './src/logger.js';
|
|
19
|
-
export { rateLimit, parseWindow } from './src/rate-limit.js';
|
|
37
|
+
export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
|
|
20
38
|
export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
|
|
21
39
|
export { cache } from './src/cache-fn.js';
|
|
22
40
|
export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
|
|
6
6
|
"main": "index.js",
|
|
@@ -15,8 +15,6 @@
|
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@webjsdev/core": "^0.7.1",
|
|
18
|
-
"chokidar": "^3.6.0",
|
|
19
|
-
"esbuild": "^0.28.0",
|
|
20
18
|
"ws": "^8.20.0"
|
|
21
19
|
},
|
|
22
20
|
"publishConfig": {
|
|
@@ -24,11 +22,11 @@
|
|
|
24
22
|
},
|
|
25
23
|
"repository": {
|
|
26
24
|
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/
|
|
25
|
+
"url": "git+https://github.com/webjsdev/webjs.git",
|
|
28
26
|
"directory": "packages/server"
|
|
29
27
|
},
|
|
30
|
-
"homepage": "https://github.com/
|
|
31
|
-
"bugs": "https://github.com/
|
|
28
|
+
"homepage": "https://github.com/webjsdev/webjs#readme",
|
|
29
|
+
"bugs": "https://github.com/webjsdev/webjs/issues",
|
|
32
30
|
"license": "MIT",
|
|
33
31
|
"keywords": [
|
|
34
32
|
"webjs",
|
package/src/actions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { digestHex } from './crypto-utils.js';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { join, relative, sep } from 'node:path';
|
|
@@ -103,7 +103,7 @@ export async function buildActionIndex(appDir, dev) {
|
|
|
103
103
|
// throw-at-load stub via `serveServerOnlyStub` instead.
|
|
104
104
|
if (!(await hasUseServerDirective(file))) continue;
|
|
105
105
|
|
|
106
|
-
const h = hashFile(file);
|
|
106
|
+
const h = await hashFile(file);
|
|
107
107
|
hashToFile.set(h, file);
|
|
108
108
|
fileToHash.set(file, h);
|
|
109
109
|
// Load module once at scan time to pick up any expose() tags.
|
|
@@ -132,9 +132,9 @@ export async function buildActionIndex(appDir, dev) {
|
|
|
132
132
|
return { hashToFile, fileToHash, httpRoutes, appDir, dev };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** @param {string} file */
|
|
136
|
-
export function hashFile(file) {
|
|
137
|
-
return
|
|
135
|
+
/** @param {string} file @returns {Promise<string>} */
|
|
136
|
+
export async function hashFile(file) {
|
|
137
|
+
return (await digestHex('SHA-256', file)).slice(0, 10);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
@@ -222,7 +222,7 @@ throw new Error(${JSON.stringify(msg)});
|
|
|
222
222
|
*/
|
|
223
223
|
export async function serveActionStub(idx, absFile) {
|
|
224
224
|
const mod = await loadModule(absFile, idx.dev);
|
|
225
|
-
const hash = idx.fileToHash.get(absFile) || hashFile(absFile);
|
|
225
|
+
const hash = idx.fileToHash.get(absFile) || await hashFile(absFile);
|
|
226
226
|
const fnNames = Object.keys(mod).filter((k) => typeof mod[k] === 'function');
|
|
227
227
|
if (typeof mod.default === 'function' && !fnNames.includes('default')) {
|
|
228
228
|
fnNames.push('default');
|
package/src/auth.js
CHANGED
|
@@ -299,7 +299,7 @@ export function createAuth(config) {
|
|
|
299
299
|
|
|
300
300
|
async function oauthRedirect(provider, opts) {
|
|
301
301
|
const req = opts.req || getRequest();
|
|
302
|
-
const origin = req ? new URL(req.url).origin : 'http://localhost:
|
|
302
|
+
const origin = req ? new URL(req.url).origin : 'http://localhost:8080';
|
|
303
303
|
const state = randomId();
|
|
304
304
|
|
|
305
305
|
const url = new URL(provider.authorizationUrl);
|
package/src/cache.js
CHANGED
|
@@ -47,6 +47,17 @@ export function memoryStore(opts = {}) {
|
|
|
47
47
|
return entry.expiresAt !== null && Date.now() > entry.expiresAt;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Only a finite, positive ttlMs sets an expiration. NaN, Infinity,
|
|
51
|
+
// 0, negative, or non-number all fall back to "no TTL" (null).
|
|
52
|
+
// Without this, NaN slips past the truthiness check and entries
|
|
53
|
+
// silently live forever, which masks bugs in caller code that
|
|
54
|
+
// computes ttl from arithmetic.
|
|
55
|
+
function expiresAtFrom(ttlMs) {
|
|
56
|
+
return typeof ttlMs === 'number' && Number.isFinite(ttlMs) && ttlMs > 0
|
|
57
|
+
? Date.now() + ttlMs
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
return {
|
|
51
62
|
async get(key) {
|
|
52
63
|
const entry = map.get(key);
|
|
@@ -61,7 +72,7 @@ export function memoryStore(opts = {}) {
|
|
|
61
72
|
map.delete(key); // remove old position
|
|
62
73
|
map.set(key, {
|
|
63
74
|
value,
|
|
64
|
-
expiresAt: ttlMs
|
|
75
|
+
expiresAt: expiresAtFrom(ttlMs),
|
|
65
76
|
});
|
|
66
77
|
evict();
|
|
67
78
|
},
|
|
@@ -73,12 +84,18 @@ export function memoryStore(opts = {}) {
|
|
|
73
84
|
if (!entry || isExpired(entry)) {
|
|
74
85
|
map.set(key, {
|
|
75
86
|
value: '1',
|
|
76
|
-
expiresAt: ttlMs
|
|
87
|
+
expiresAt: expiresAtFrom(ttlMs),
|
|
77
88
|
});
|
|
78
89
|
return 1;
|
|
79
90
|
}
|
|
80
91
|
const next = parseInt(entry.value, 10) + 1;
|
|
92
|
+
// Mutate value + re-insert so the bumped key counts as recent
|
|
93
|
+
// for LRU eviction. Without the re-insert, a hot rate-limit
|
|
94
|
+
// bucket stays at its original position and gets evicted ahead
|
|
95
|
+
// of less-active keys.
|
|
81
96
|
entry.value = String(next);
|
|
97
|
+
map.delete(key);
|
|
98
|
+
map.set(key, entry);
|
|
82
99
|
return next;
|
|
83
100
|
},
|
|
84
101
|
};
|
package/src/check.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { readdir, 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
|
+
import {
|
|
5
|
+
redactStringsAndTemplates,
|
|
6
|
+
extractWebComponentClassBodies,
|
|
7
|
+
matchClosingBrace,
|
|
8
|
+
} from './js-scan.js';
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Convention validator for webjs apps.
|
|
@@ -92,13 +97,23 @@ export const RULES = [
|
|
|
92
97
|
{
|
|
93
98
|
name: 'erasable-typescript-only',
|
|
94
99
|
description:
|
|
95
|
-
'Apps must opt into TypeScript\'s `erasableSyntaxOnly: true` so the compiler rejects non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, import = require) at edit time. webjs strips types via Node\'s built-in `module.stripTypeScriptTypes`, which only supports erasable TypeScript and produces byte-exact position preservation (no sourcemap overhead). Files using non-erasable syntax
|
|
100
|
+
'Apps must opt into TypeScript\'s `erasableSyntaxOnly: true` so the compiler rejects non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, import = require) at edit time. webjs strips types via Node\'s built-in `module.stripTypeScriptTypes`, which only supports erasable TypeScript and produces byte-exact position preservation (no sourcemap overhead). Files using non-erasable syntax fail at strip time and the dev server returns a 500 pointing at the no-non-erasable-typescript rule; webjs is buildless end-to-end and has no bundler fallback. The rule checks the project\'s tsconfig.json and warns when `erasableSyntaxOnly` is missing or set to false. Set `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json to comply.',
|
|
96
101
|
},
|
|
97
102
|
{
|
|
98
103
|
name: 'use-server-needs-extension',
|
|
99
104
|
description:
|
|
100
105
|
'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.',
|
|
101
106
|
},
|
|
107
|
+
{
|
|
108
|
+
name: 'no-non-erasable-typescript',
|
|
109
|
+
description:
|
|
110
|
+
'Scans .ts / .mts source for the four non-erasable TypeScript constructs (enum declarations, namespace blocks with value statements, constructor parameter properties, and `import = require`) that the framework\'s type-stripper rejects at request time. Companion to `erasable-typescript-only`: that rule checks the tsconfig flag, this rule checks the actual source. Both run by default so the flag check catches violations early in the editor while the source scan catches violations even if the tsconfig flag is missing or the rule is bypassed. Skips node_modules, dist, build, .git, .next, and _private folders.',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'gitignore-vendor-not-ignored',
|
|
114
|
+
description:
|
|
115
|
+
'Verifies the `.gitignore` exception for `.webjs/vendor/` is structurally correct via `git check-ignore`. The intended pattern is `.webjs/*` (NOT `.webjs/`) plus `!.webjs/vendor/` plus `!.webjs/vendor/**`. The common-looking pattern `.webjs/` excludes the directory itself, after which git cannot re-include children (gitignore semantics: a parent exclusion blocks child negations). Without this rule, an AI agent or human editor would silently break `webjs vendor pin` by simplifying the pattern; the failure is invisible until production. Rule fires when the working directory is a git repo and a `.gitignore` exists; skipped when neither is true.',
|
|
116
|
+
},
|
|
102
117
|
];
|
|
103
118
|
|
|
104
119
|
/** Set of all known rule names for fast lookup. */
|
|
@@ -241,72 +256,6 @@ function countExportedFunctions(content) {
|
|
|
241
256
|
return seen.size;
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
/**
|
|
245
|
-
* Extract the body of every `class … extends WebComponent { … }` block.
|
|
246
|
-
* Brace-counts to handle nested template literals, methods, and arrow
|
|
247
|
-
* functions. String state is tracked so braces inside strings/templates
|
|
248
|
-
* don't shift depth.
|
|
249
|
-
*
|
|
250
|
-
* @param {string} content
|
|
251
|
-
* @returns {string[]}
|
|
252
|
-
*/
|
|
253
|
-
function extractWebComponentClassBodies(content) {
|
|
254
|
-
const bodies = [];
|
|
255
|
-
const re = /class\s+\w+\s+extends\s+WebComponent\s*\{/g;
|
|
256
|
-
let m;
|
|
257
|
-
while ((m = re.exec(content)) !== null) {
|
|
258
|
-
const bodyStart = m.index + m[0].length;
|
|
259
|
-
const end = matchClosingBrace(content, bodyStart);
|
|
260
|
-
if (end !== -1) bodies.push(content.slice(bodyStart, end));
|
|
261
|
-
}
|
|
262
|
-
return bodies;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Walk forward from `start` (just after an opening `{`) and return the
|
|
267
|
-
* index of the matching `}`. Tracks string/template-literal state so
|
|
268
|
-
* `}` inside `'…'`, `"…"`, or backtick templates don't decrement depth.
|
|
269
|
-
* Returns -1 if no balanced brace is found.
|
|
270
|
-
*
|
|
271
|
-
* @param {string} s
|
|
272
|
-
* @param {number} start
|
|
273
|
-
*/
|
|
274
|
-
function matchClosingBrace(s, start) {
|
|
275
|
-
let depth = 1;
|
|
276
|
-
let i = start;
|
|
277
|
-
let str = ''; // '', "'", '"', or '`'
|
|
278
|
-
while (i < s.length) {
|
|
279
|
-
const c = s[i];
|
|
280
|
-
if (str) {
|
|
281
|
-
if (c === '\\') { i += 2; continue; }
|
|
282
|
-
if (c === str) str = '';
|
|
283
|
-
else if (str === '`' && c === '$' && s[i + 1] === '{') {
|
|
284
|
-
// template hole: count its closing `}` toward our brace depth.
|
|
285
|
-
depth++;
|
|
286
|
-
i += 2;
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
i++;
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
|
|
293
|
-
if (c === '/' && s[i + 1] === '/') { // line comment
|
|
294
|
-
while (i < s.length && s[i] !== '\n') i++;
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
if (c === '/' && s[i + 1] === '*') { // block comment
|
|
298
|
-
i += 2;
|
|
299
|
-
while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++;
|
|
300
|
-
i += 2;
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
if (c === '{') depth++;
|
|
304
|
-
else if (c === '}') { depth--; if (depth === 0) return i; }
|
|
305
|
-
i++;
|
|
306
|
-
}
|
|
307
|
-
return -1;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
259
|
/**
|
|
311
260
|
* Find every `<key>:` entry inside the first `static properties = { … }`
|
|
312
261
|
* literal in `classBody`. Returns the bare property names: the keys
|
|
@@ -492,8 +441,15 @@ export async function checkConventions(appDir, opts) {
|
|
|
492
441
|
}
|
|
493
442
|
}
|
|
494
443
|
|
|
495
|
-
// Collect all JS/TS files in the app directory
|
|
496
|
-
|
|
444
|
+
// Collect all JS/TS files in the app directory. Each entry carries
|
|
445
|
+
// both the raw `content` (for rules that need verbatim source: the
|
|
446
|
+
// `'use server'` directive detector, the `.gitignore` reader, etc.)
|
|
447
|
+
// and a `scan` view with comments, string contents, and
|
|
448
|
+
// template-literal bodies redacted to whitespace. Rules that
|
|
449
|
+
// pattern-match across raw source should consume `scan` so docs-
|
|
450
|
+
// page code-block examples and JSDoc samples don't trigger false
|
|
451
|
+
// positives.
|
|
452
|
+
/** @type {{ abs: string, rel: string, content: string, scan: string }[]} */
|
|
497
453
|
const files = [];
|
|
498
454
|
for await (const abs of walk(appDir, (p) => /\.m?[jt]sx?$/.test(p))) {
|
|
499
455
|
const rel = relative(appDir, abs);
|
|
@@ -503,7 +459,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
503
459
|
} catch {
|
|
504
460
|
continue;
|
|
505
461
|
}
|
|
506
|
-
files.push({ abs, rel, content });
|
|
462
|
+
files.push({ abs, rel, content, scan: redactStringsAndTemplates(content) });
|
|
507
463
|
}
|
|
508
464
|
|
|
509
465
|
// --- Rule: actions-in-modules ---
|
|
@@ -556,15 +512,19 @@ export async function checkConventions(appDir, opts) {
|
|
|
556
512
|
|
|
557
513
|
// --- Rule: components-have-register ---
|
|
558
514
|
if (isRuleEnabled('components-have-register', overrides)) {
|
|
559
|
-
for (const { rel,
|
|
515
|
+
for (const { rel, scan } of files) {
|
|
560
516
|
if (!isComponentFile(rel)) continue;
|
|
561
|
-
//
|
|
562
|
-
|
|
517
|
+
// Use redacted source so a code-example string like
|
|
518
|
+
// `Foo.register('bar')` inside a tagged template literal does
|
|
519
|
+
// not falsely satisfy the rule for a sibling unregistered
|
|
520
|
+
// class. Real register() calls live at top level where the
|
|
521
|
+
// redactor leaves them alone.
|
|
522
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
563
523
|
// Accept either registration pattern:
|
|
564
524
|
// Counter.register('tag') (webjs idiom)
|
|
565
525
|
// customElements.define('tag', Counter) (native)
|
|
566
|
-
if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"]/.test(
|
|
567
|
-
if (/\bcustomElements\.define\s*\(/.test(
|
|
526
|
+
if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"`]/.test(scan)) continue;
|
|
527
|
+
if (/\bcustomElements\.define\s*\(/.test(scan)) continue;
|
|
568
528
|
violations.push({
|
|
569
529
|
rule: 'components-have-register',
|
|
570
530
|
file: rel,
|
|
@@ -576,9 +536,13 @@ export async function checkConventions(appDir, opts) {
|
|
|
576
536
|
|
|
577
537
|
// --- Rule: reactive-props-use-declare ---
|
|
578
538
|
if (isRuleEnabled('reactive-props-use-declare', overrides)) {
|
|
579
|
-
for (const { rel,
|
|
580
|
-
|
|
581
|
-
|
|
539
|
+
for (const { rel, scan } of files) {
|
|
540
|
+
// Use redacted source so test-fixture-style strings like
|
|
541
|
+
// `class X extends WebComponent { x = 0 }` inside template
|
|
542
|
+
// literals don't trip the rule. Real declarations live at
|
|
543
|
+
// top-level code where the redactor leaves them alone.
|
|
544
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
545
|
+
for (const body of extractWebComponentClassBodies(scan)) {
|
|
582
546
|
const propNames = extractStaticPropertyNames(body);
|
|
583
547
|
if (propNames.size === 0) continue;
|
|
584
548
|
for (const bad of findFieldInitializers(body, propNames)) {
|
|
@@ -737,8 +701,8 @@ export async function checkConventions(appDir, opts) {
|
|
|
737
701
|
violations.push({
|
|
738
702
|
rule: 'no-json-data-files',
|
|
739
703
|
file: s.rel,
|
|
740
|
-
message: `${s.why}. webjs apps must persist data with Prisma + SQLite (already wired up: see prisma/schema.prisma and lib/prisma.ts), not JSON files.`,
|
|
741
|
-
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.ts'\`. Delete ${s.rel} once the data has moved.`,
|
|
704
|
+
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.`,
|
|
705
|
+
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.`,
|
|
742
706
|
});
|
|
743
707
|
}
|
|
744
708
|
}
|
|
@@ -781,15 +745,16 @@ export async function checkConventions(appDir, opts) {
|
|
|
781
745
|
}
|
|
782
746
|
|
|
783
747
|
// --- Rule: erasable-typescript-only ---
|
|
784
|
-
// The dev server's
|
|
748
|
+
// The dev server's type-stripper is Node's built-in
|
|
785
749
|
// module.stripTypeScriptTypes, which rejects non-erasable TS (enum,
|
|
786
750
|
// namespace with values, constructor parameter properties, legacy
|
|
787
|
-
// decorators, `import = require`).
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
751
|
+
// decorators, `import = require`). There is no fallback: non-erasable
|
|
752
|
+
// syntax is rejected at request time with a 500. Enforce TS-side
|
|
753
|
+
// rejection of those patterns via `compilerOptions.erasableSyntaxOnly:
|
|
754
|
+
// true` in tsconfig.json so violations surface as red squiggles in
|
|
755
|
+
// the editor before they ever hit the dev server. The companion
|
|
756
|
+
// no-non-erasable-typescript rule (below) catches violations even if
|
|
757
|
+
// the tsconfig flag is unset.
|
|
793
758
|
if (isRuleEnabled('erasable-typescript-only', overrides)) {
|
|
794
759
|
let tsconfigContent = null;
|
|
795
760
|
try {
|
|
@@ -816,8 +781,8 @@ export async function checkConventions(appDir, opts) {
|
|
|
816
781
|
file: 'tsconfig.json',
|
|
817
782
|
message:
|
|
818
783
|
flag === false
|
|
819
|
-
? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators)
|
|
820
|
-
: '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a
|
|
784
|
+
? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators) fails at strip time and the dev server returns a 500. webjs is buildless end-to-end and has no bundler fallback; turn the flag on so the TypeScript compiler catches non-erasable constructs as red squiggles at edit time.'
|
|
785
|
+
: '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a 500 at runtime.',
|
|
821
786
|
fix:
|
|
822
787
|
'Set `"erasableSyntaxOnly": true` under `compilerOptions` in tsconfig.json. Replace any existing `enum` declarations with `const X = { ... } as const` plus a `type X = typeof X[keyof typeof X]` union. Replace constructor parameter properties with explicit field declarations + assignments.',
|
|
823
788
|
});
|
|
@@ -825,6 +790,99 @@ export async function checkConventions(appDir, opts) {
|
|
|
825
790
|
}
|
|
826
791
|
}
|
|
827
792
|
|
|
793
|
+
// --- Rule: no-non-erasable-typescript ---
|
|
794
|
+
// Scans .ts source for the four non-erasable TypeScript constructs
|
|
795
|
+
// that the runtime stripper rejects. Complement to
|
|
796
|
+
// erasable-typescript-only: the flag check catches the case where
|
|
797
|
+
// the user opts into the tsconfig flag; this scan catches the
|
|
798
|
+
// case where the flag is missing OR the user has bypassed it and
|
|
799
|
+
// written offending syntax anyway. Both rules ship enabled by
|
|
800
|
+
// default so violators get the strongest signal possible.
|
|
801
|
+
if (isRuleEnabled('no-non-erasable-typescript', overrides)) {
|
|
802
|
+
/** @type {Array<{ name: string, regex: RegExp, fix: string }>} */
|
|
803
|
+
const NON_ERASABLE_PATTERNS = [
|
|
804
|
+
{
|
|
805
|
+
name: 'enum',
|
|
806
|
+
// Matches `enum X {`, `export enum X {`, `const enum X {`,
|
|
807
|
+
// `declare enum X {`. Requires uppercase first letter on the
|
|
808
|
+
// identifier to avoid matching variables literally named "enum"
|
|
809
|
+
// in user code (rare but possible).
|
|
810
|
+
regex: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?(?:const[ \t]+)?enum[ \t]+[A-Z]\w*[ \t]*\{/m,
|
|
811
|
+
fix: 'Replace `enum Foo { A, B }` with `const Foo = { A: "A", B: "B" } as const; type Foo = typeof Foo[keyof typeof Foo];`.',
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
name: 'namespace with values',
|
|
815
|
+
// Matches `namespace Foo { ... <value statement> ... }` at top
|
|
816
|
+
// level. Type-only namespaces (which ARE erasable) won't contain
|
|
817
|
+
// `let|const|var|function|class` as statements, so this catches
|
|
818
|
+
// only the value-carrying form. False positives possible for
|
|
819
|
+
// type-only namespaces that contain those words in type aliases;
|
|
820
|
+
// accept this as a soft warning.
|
|
821
|
+
regex: /^[ \t]*(?:export[ \t]+)?namespace[ \t]+\w+[ \t]*\{[\s\S]*?\b(?:let|const|var|function|class)\b/m,
|
|
822
|
+
fix: 'Replace `namespace Foo { export const x = 1 }` with `export const Foo = { x: 1 } as const;` or split the contents into separate modules.',
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
name: 'constructor parameter property',
|
|
826
|
+
// Matches `constructor(public x: T)`, `constructor(private foo, ...)`,
|
|
827
|
+
// `constructor(readonly bar)`. Looks for one of the four access
|
|
828
|
+
// modifiers immediately followed by an identifier inside the
|
|
829
|
+
// constructor's parameter list.
|
|
830
|
+
regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/,
|
|
831
|
+
fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`. The reactive-props-use-declare rule has the framework-specific shape: `declare x: number;` (no value) plus the assignment in the constructor body.',
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
name: 'import = require',
|
|
835
|
+
// TypeScript-style CommonJS import. Catches `import foo =
|
|
836
|
+
// require("bar")` and `export import foo = require("bar")`.
|
|
837
|
+
regex: /^[ \t]*(?:export[ \t]+)?import[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/m,
|
|
838
|
+
fix: 'Replace `import foo = require("bar")` with `import * as foo from "bar"` or `import { something } from "bar"`.',
|
|
839
|
+
},
|
|
840
|
+
];
|
|
841
|
+
|
|
842
|
+
// Walk every .ts / .mts file under appDir, skipping node_modules,
|
|
843
|
+
// build outputs, version control, and the framework's own private
|
|
844
|
+
// folders. Match the conventional excludes that fs-walk.js's caller
|
|
845
|
+
// contract expects.
|
|
846
|
+
for await (const abs of walk(appDir, (p) => /\.m?ts$/.test(p))) {
|
|
847
|
+
// Skip anything inside node_modules or common build / cache dirs.
|
|
848
|
+
const relPath = relative(appDir, abs);
|
|
849
|
+
if (
|
|
850
|
+
relPath.includes('node_modules' + sep) ||
|
|
851
|
+
relPath.startsWith('dist' + sep) ||
|
|
852
|
+
relPath.startsWith('build' + sep) ||
|
|
853
|
+
relPath.startsWith('.next' + sep) ||
|
|
854
|
+
relPath.startsWith('.git' + sep) ||
|
|
855
|
+
relPath.split(sep).some((s) => s.startsWith('_'))
|
|
856
|
+
) {
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
let content;
|
|
860
|
+
try {
|
|
861
|
+
content = await readFile(abs, 'utf8');
|
|
862
|
+
} catch {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
// Redact comments, string contents, and template-literal bodies
|
|
866
|
+
// so docs-page code examples like `<pre>enum Direction { ... }</pre>`
|
|
867
|
+
// inside `html\`...\`` template literals don't trip the rule.
|
|
868
|
+
// The redactor preserves line + column so the reported line
|
|
869
|
+
// number still maps to the right place in the original.
|
|
870
|
+
const scan = redactStringsAndTemplates(content);
|
|
871
|
+
for (const { name, regex, fix } of NON_ERASABLE_PATTERNS) {
|
|
872
|
+
const m = scan.match(regex);
|
|
873
|
+
if (m && typeof m.index === 'number') {
|
|
874
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
875
|
+
violations.push({
|
|
876
|
+
rule: 'no-non-erasable-typescript',
|
|
877
|
+
file: relPath,
|
|
878
|
+
message: `Non-erasable TypeScript construct (${name}) detected at line ${line}. The framework's type-stripper rejects this at request time with a 500.`,
|
|
879
|
+
fix,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
828
886
|
// --- Rule: use-server-needs-extension ---
|
|
829
887
|
// Catch files that declare `'use server'` at the top but lack the
|
|
830
888
|
// `.server.{js,ts}` extension. Under the two-marker convention the
|
|
@@ -849,17 +907,23 @@ export async function checkConventions(appDir, opts) {
|
|
|
849
907
|
|
|
850
908
|
// --- Rule: tag-name-has-hyphen ---
|
|
851
909
|
if (isRuleEnabled('tag-name-has-hyphen', overrides)) {
|
|
852
|
-
for (const { rel,
|
|
910
|
+
for (const { rel, scan } of files) {
|
|
853
911
|
if (!isComponentFile(rel)) continue;
|
|
912
|
+
// Use redacted source. A `register('tag')` call inside a
|
|
913
|
+
// TAGGED template literal (docs-page code example) is blanked.
|
|
914
|
+
// Calls at top level keep their structure AND their string
|
|
915
|
+
// argument. Quote style can be ', ", or ` (untagged backtick
|
|
916
|
+
// literals survive the redactor, like single/double-quote
|
|
917
|
+
// strings).
|
|
854
918
|
const patterns = [
|
|
855
|
-
// Class.register('tag')
|
|
856
|
-
/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"])([^'"]+)\1/g,
|
|
857
|
-
// customElements.define('tag', Class)
|
|
858
|
-
/\bcustomElements\.define\s*\(\s*(['"])([^'"]+)\1/g,
|
|
919
|
+
// Class.register('tag') / register("tag") / register(`tag`)
|
|
920
|
+
/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
921
|
+
// customElements.define('tag', Class) and quote variants
|
|
922
|
+
/\bcustomElements\.define\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
859
923
|
];
|
|
860
924
|
for (const re of patterns) {
|
|
861
925
|
let match;
|
|
862
|
-
while ((match = re.exec(
|
|
926
|
+
while ((match = re.exec(scan)) !== null) {
|
|
863
927
|
const tagName = match[2];
|
|
864
928
|
if (!tagName.includes('-')) {
|
|
865
929
|
violations.push({
|
|
@@ -874,5 +938,72 @@ export async function checkConventions(appDir, opts) {
|
|
|
874
938
|
}
|
|
875
939
|
}
|
|
876
940
|
|
|
941
|
+
// --- Rule: gitignore-vendor-not-ignored ---
|
|
942
|
+
// The .gitignore pattern for .webjs/vendor/ is subtle: `.webjs/`
|
|
943
|
+
// alone excludes the directory entirely and git can't re-include
|
|
944
|
+
// children of an excluded parent. The correct pattern is `.webjs/*`
|
|
945
|
+
// plus `!.webjs/vendor/` plus `!.webjs/vendor/**`. AI agents
|
|
946
|
+
// and human reviewers frequently "simplify" this back to `.webjs/`,
|
|
947
|
+
// silently breaking `webjs vendor pin`.
|
|
948
|
+
//
|
|
949
|
+
// This rule verifies the actual gitignore behavior by spawning
|
|
950
|
+
// `git check-ignore` against a representative pin-file path. If
|
|
951
|
+
// git reports the file as ignored, the pattern is broken.
|
|
952
|
+
//
|
|
953
|
+
// Skipped when the directory isn't a git repo or has no .gitignore
|
|
954
|
+
// (the user hasn't opted into version control yet).
|
|
955
|
+
if (isRuleEnabled('gitignore-vendor-not-ignored', overrides)) {
|
|
956
|
+
const hasGit = await pathExists(join(appDir, '.git'));
|
|
957
|
+
const hasGitignore = await pathExists(join(appDir, '.gitignore'));
|
|
958
|
+
if (hasGit && hasGitignore) {
|
|
959
|
+
const { spawnSync } = await import('node:child_process');
|
|
960
|
+
// Check two representative paths: the pin manifest AND a sample
|
|
961
|
+
// downloaded bundle. A `.gitignore` that allows the manifest
|
|
962
|
+
// but blocks bundles (e.g. `*.js` higher up) would still break
|
|
963
|
+
// `webjs vendor pin --download`. `git check-ignore -q` exits 0
|
|
964
|
+
// when ignored, 1 when not ignored.
|
|
965
|
+
const probes = [
|
|
966
|
+
'.webjs/vendor/importmap.json',
|
|
967
|
+
'.webjs/vendor/sample-pkg@1.0.0.js',
|
|
968
|
+
];
|
|
969
|
+
for (const probe of probes) {
|
|
970
|
+
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
971
|
+
cwd: appDir,
|
|
972
|
+
stdio: 'pipe',
|
|
973
|
+
});
|
|
974
|
+
if (result.status === 0) {
|
|
975
|
+
violations.push({
|
|
976
|
+
rule: 'gitignore-vendor-not-ignored',
|
|
977
|
+
file: '.gitignore',
|
|
978
|
+
message:
|
|
979
|
+
`${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ and they MUST be committed for production deploys to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line in .gitignore that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second possible cause is a broader rule (e.g. \`*.js\` at root) that hides bundle files added by \`webjs vendor pin --download\`.`,
|
|
980
|
+
fix:
|
|
981
|
+
'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
|
|
982
|
+
' .webjs/*\n' +
|
|
983
|
+
' !.webjs/vendor/\n' +
|
|
984
|
+
' !.webjs/vendor/**\n' +
|
|
985
|
+
'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
877
992
|
return violations;
|
|
878
993
|
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Async fs.exists shim. Returns true if the path exists at all (file
|
|
997
|
+
* or directory), false on ENOENT or any other stat failure.
|
|
998
|
+
*
|
|
999
|
+
* @param {string} p absolute path
|
|
1000
|
+
* @returns {Promise<boolean>}
|
|
1001
|
+
*/
|
|
1002
|
+
async function pathExists(p) {
|
|
1003
|
+
try {
|
|
1004
|
+
await stat(p);
|
|
1005
|
+
return true;
|
|
1006
|
+
} catch {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
}
|