@webjsdev/server 0.7.3 → 0.8.1

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 CHANGED
@@ -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 auto-bundling** for npm packages via import maps, backed
22
- by esbuild (Vite-style `optimizeDeps`).
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
 
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 { scanBareImports, extractPackageName, bundlePackage, vendorImportMapEntries, clearVendorCache, serveVendorBundle } from './src/vendor.js';
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.7.3",
3
+ "version": "0.8.1",
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": {
package/src/actions.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash } from 'node:crypto';
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';
@@ -46,8 +46,9 @@ async function rpcResponse(payload, init = {}) {
46
46
  * ignored.
47
47
  *
48
48
  * The server:
49
- * 1. Scans the app tree on boot, classifying server files into
50
- * RPC-callable actions vs. server-only utilities.
49
+ * 1. Scans the app tree lazily on the first request (in `ensureReady`),
50
+ * classifying server files into RPC-callable actions vs. server-only
51
+ * utilities. Hashing is eager-per-file; only `expose()` files load.
51
52
  * 2. Serves a generated ES-module stub when the browser imports
52
53
  * the file URL (an RPC stub for actions, a throw-at-load stub
53
54
  * for server-only utilities).
@@ -103,10 +104,25 @@ export async function buildActionIndex(appDir, dev) {
103
104
  // throw-at-load stub via `serveServerOnlyStub` instead.
104
105
  if (!(await hasUseServerDirective(file))) continue;
105
106
 
106
- const h = hashFile(file);
107
+ const h = await hashFile(file);
107
108
  hashToFile.set(h, file);
108
109
  fileToHash.set(file, h);
109
- // Load module once at scan time to pick up any expose() tags.
110
+ // Pure-RPC actions are NOT executed at boot: invokeAction and
111
+ // serveActionStub import the module on demand (first RPC call / first stub
112
+ // fetch), so the hash index above is all the analysis needs. Eagerly
113
+ // running every server module (and its transitive Prisma init, DB
114
+ // connects, etc.) would be wasted work. The one thing that DOES need eager loading is expose(),
115
+ // which registers a REST route the router must know before any request can
116
+ // hit it. So load only files that REFERENCE expose. We match the bare
117
+ // `expose` identifier (not `expose(`) so an aliased import
118
+ // (`import { expose as exp }`, whose import clause still names `expose`) is
119
+ // not missed: missing it would silently 404 that file's REST route. A stray
120
+ // mention in a comment or string only over-matches, costing one harmless
121
+ // extra module load; the common pure-RPC file never names `expose` and so
122
+ // still defers entirely.
123
+ let src = '';
124
+ try { src = await readFile(file, 'utf8'); } catch {}
125
+ if (!/\bexpose\b/.test(src)) continue;
110
126
  try {
111
127
  const mod = await loadModule(file, dev);
112
128
  for (const [name, fn] of Object.entries(mod)) {
@@ -132,9 +148,9 @@ export async function buildActionIndex(appDir, dev) {
132
148
  return { hashToFile, fileToHash, httpRoutes, appDir, dev };
133
149
  }
134
150
 
135
- /** @param {string} file */
136
- export function hashFile(file) {
137
- return createHash('sha256').update(file).digest('hex').slice(0, 10);
151
+ /** @param {string} file @returns {Promise<string>} */
152
+ export async function hashFile(file) {
153
+ return (await digestHex('SHA-256', file)).slice(0, 10);
138
154
  }
139
155
 
140
156
  /**
@@ -222,7 +238,7 @@ throw new Error(${JSON.stringify(msg)});
222
238
  */
223
239
  export async function serveActionStub(idx, absFile) {
224
240
  const mod = await loadModule(absFile, idx.dev);
225
- const hash = idx.fileToHash.get(absFile) || hashFile(absFile);
241
+ const hash = idx.fileToHash.get(absFile) || await hashFile(absFile);
226
242
  const fnNames = Object.keys(mod).filter((k) => typeof mod[k] === 'function');
227
243
  if (typeof mod.default === 'function' && !fnNames.includes('default')) {
228
244
  fnNames.push('default');
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 ? Date.now() + ttlMs : null,
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 ? Date.now() + ttlMs : null,
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.
@@ -57,7 +62,7 @@ export const RULES = [
57
62
  {
58
63
  name: 'components-have-register',
59
64
  description:
60
- 'Component files that define a class extending WebComponent must register the class with ClassName.register(\'tag\') (or customElements.define). The server-side scanner derives the module URL from the file path at boot.',
65
+ 'Component files that define a class extending WebComponent must register the class with ClassName.register(\'tag\') (or customElements.define). The server-side scanner derives the module URL from the file path.',
61
66
  },
62
67
  {
63
68
  name: 'no-server-env-in-components',
@@ -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 fall back to esbuild + inline sourcemap, which is supported as a safety net for third-party deps but should not be the path your own code takes. 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.',
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
- /** @type {{ abs: string, rel: string, content: string }[]} */
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, content } of files) {
515
+ for (const { rel, scan } of files) {
560
516
  if (!isComponentFile(rel)) continue;
561
- // Check if it defines a class extending WebComponent
562
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
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(content)) continue;
567
- if (/\bcustomElements\.define\s*\(/.test(content)) continue;
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, content } of files) {
580
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
581
- for (const body of extractWebComponentClassBodies(content)) {
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 primary type-stripper is Node's built-in
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`). The fallback path is esbuild +
788
- // inline sourcemap, which is a real ~3x wire-byte hit on every .ts
789
- // request that takes it. Enforce TS-side rejection of those patterns
790
- // via `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json so
791
- // violations surface as red squiggles in the editor before they ever
792
- // hit the dev server.
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) falls back to esbuild + inline sourcemap on every request, costing ~3x wire bytes and losing byte-exact stack-trace positions.'
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 slower runtime fallback.',
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, content } of files) {
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(content)) !== null) {
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
+ }