Skip to content

Commit 490372a

Browse files
committed
feat(check): erasable-typescript-only rule + ship erasableSyntaxOnly in tsconfigs
Adds compilerOptions.erasableSyntaxOnly to: the scaffold tsconfig generator (packages/cli/lib/create.js), the blog example, the docs site, the marketing site. TypeScript 5.8+ rejects enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, and import = require at compile time when this flag is set. Violations surface as red squiggles in the editor, well before they would hit Node's built-in strip-types and trigger the (slower, sourcemap-bearing) esbuild fallback path. Adds a new webjs check rule, erasable-typescript-only, that loads the project's tsconfig.json and warns when erasableSyntaxOnly is missing or set to false. Lighter than a custom TS parser because the actual checking is delegated to tsc; this rule just verifies the tsconfig opted in. Verified against the blog example: passes with the flag on, flags a single violation when flipped to false.
1 parent 29f1645 commit 490372a

5 files changed

Lines changed: 80 additions & 6 deletions

File tree

docs/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"allowJs": true,
1010
"checkJs": true,
1111
"allowImportingTsExtensions": true,
12-
"skipLibCheck": true
12+
"skipLibCheck": true,
13+
"erasableSyntaxOnly": true
1314
},
1415
"include": ["app/**/*", "components/**/*"],
1516
"exclude": ["node_modules", ".webjs"]

examples/blog/tsconfig.json

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
"target": "ES2022",
44
"module": "NodeNext",
55
"moduleResolution": "NodeNext",
6-
"lib": ["ES2022", "DOM", "DOM.Iterable"],
6+
"lib": [
7+
"ES2022",
8+
"DOM",
9+
"DOM.Iterable"
10+
],
711
"strict": true,
812
"noEmit": true,
913
"checkJs": true,
@@ -12,9 +16,12 @@
1216
"skipLibCheck": true,
1317
"isolatedModules": true,
1418
"verbatimModuleSyntax": false,
19+
"erasableSyntaxOnly": true,
1520
"jsx": "preserve",
1621
"plugins": [
17-
{ "name": "@webjskit/ts-plugin" }
22+
{
23+
"name": "@webjskit/ts-plugin"
24+
}
1825
]
1926
},
2027
"include": [
@@ -25,5 +32,9 @@
2532
"middleware.js",
2633
"middleware.ts"
2734
],
28-
"exclude": ["node_modules", ".webjs", "prisma/migrations"]
29-
}
35+
"exclude": [
36+
"node_modules",
37+
".webjs",
38+
"prisma/migrations"
39+
]
40+
}

packages/cli/lib/create.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,17 @@ export async function scaffoldApp(name, cwd, opts = {}) {
224224
noEmit: true,
225225
allowImportingTsExtensions: true,
226226
skipLibCheck: true,
227+
// webjs uses Node's built-in type-stripping (`process.features.
228+
// typescript === 'strip'`) which preserves source positions
229+
// byte-exactly. The constraint is that TypeScript must be
230+
// "erasable": no `enum`, no `namespace` with values, no
231+
// constructor parameter properties, no legacy decorators with
232+
// `emitDecoratorMetadata`. erasableSyntaxOnly makes the
233+
// compiler reject those at edit time so violations surface as
234+
// red squiggles instead of runtime ERR_UNSUPPORTED_TYPESCRIPT_
235+
// SYNTAX errors. Use a `const` object + union for enum-shaped
236+
// values; write fields + constructor assignments explicitly.
237+
erasableSyntaxOnly: true,
227238
// @webjskit/ts-plugin gives the editor:
228239
// • type-check + diagnostics inside html`` templates (via the
229240
// ts-lit-plugin it bundles internally)

packages/server/src/check.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ export const RULES = [
8787
description:
8888
'Only the root layout (app/layout.{js,ts}) may write a <!doctype>/<html>/<head>/<body> shell to override default <html lang>, <body class>, etc. Non-root layouts (app/<segment>/layout.{js,ts}) and pages (app/**/page.{js,ts}) must not: the framework auto-emits the wrapper around the whole composition, so a nested shell ends up nested inside <body> where browsers drop it. Triggers on any of <!doctype>, <html, <head, <body in a non-root layout or page.',
8989
},
90+
{
91+
name: 'erasable-typescript-only',
92+
description:
93+
'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.',
94+
},
9095
];
9196

9297
/** Set of all known rule names for fast lookup. */
@@ -754,6 +759,51 @@ export async function checkConventions(appDir, opts) {
754759
}
755760
}
756761

762+
// --- Rule: erasable-typescript-only ---
763+
// The dev server's primary type-stripper is Node's built-in
764+
// module.stripTypeScriptTypes, which rejects non-erasable TS (enum,
765+
// namespace with values, constructor parameter properties, legacy
766+
// decorators, `import = require`). The fallback path is esbuild +
767+
// inline sourcemap, which is a real ~3x wire-byte hit on every .ts
768+
// request that takes it. Enforce TS-side rejection of those patterns
769+
// via `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json so
770+
// violations surface as red squiggles in the editor before they ever
771+
// hit the dev server.
772+
if (isRuleEnabled('erasable-typescript-only', overrides)) {
773+
let tsconfigContent = null;
774+
try {
775+
tsconfigContent = await readFile(join(appDir, 'tsconfig.json'), 'utf8');
776+
} catch {
777+
// No tsconfig.json (pure JS app). Skip the rule.
778+
}
779+
if (tsconfigContent != null) {
780+
let parsed = null;
781+
try {
782+
const stripped = tsconfigContent
783+
.replace(/\/\/.*$/gm, '')
784+
.replace(/\/\*[\s\S]*?\*\//g, '')
785+
.replace(/,(\s*[}\]])/g, '$1');
786+
parsed = JSON.parse(stripped);
787+
} catch {
788+
parsed = null;
789+
}
790+
const compilerOptions = parsed && typeof parsed === 'object' ? parsed.compilerOptions : null;
791+
const flag = compilerOptions && typeof compilerOptions === 'object' ? compilerOptions.erasableSyntaxOnly : undefined;
792+
if (flag !== true) {
793+
violations.push({
794+
rule: 'erasable-typescript-only',
795+
file: 'tsconfig.json',
796+
message:
797+
flag === false
798+
? '`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.'
799+
: '`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.',
800+
fix:
801+
'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.',
802+
});
803+
}
804+
}
805+
}
806+
757807
// --- Rule: tag-name-has-hyphen ---
758808
if (isRuleEnabled('tag-name-has-hyphen', overrides)) {
759809
for (const { rel, content } of files) {

website/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"strict": true,
88
"noEmit": true,
99
"allowImportingTsExtensions": true,
10-
"skipLibCheck": true
10+
"skipLibCheck": true,
11+
"erasableSyntaxOnly": true
1112
},
1213
"include": ["app/**/*", "components/**/*"],
1314
"exclude": ["node_modules", ".webjs"]

0 commit comments

Comments
 (0)