feat(server): replace esbuild TS stripping with Node 24+ strip-types#9
Merged
Merged
Conversation
Node 24 made TypeScript type-stripping default-on (no flag required) and stable (no experimental warning). This is the floor that lets the framework drop its custom esbuild module loader and rely on Node's built-in stripper for server-side .ts imports.
Server-side .ts imports now use Node 24+'s default type-stripping
natively. The module.register('./esbuild-loader.js') hook and the
loader file itself are deleted: SSR and the test runner both pick
up .ts files without any framework bootstrap.
Browser-bound .ts files served by the dev server's tsResponse path
go through a new stripTs(source, abs) helper that:
1. Tries module.stripTypeScriptTypes (Node built-in). The whitespace
replacement preserves every (line, column) position byte-exactly,
so no sourcemap is emitted. ~70% wire-byte reduction vs the prior
esbuild + inline sourcemap pipeline.
2. Catches ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX and falls back to
esbuild.transform with sourcemap=inline. This handles the rare
third-party .ts dependency that uses enum, namespace, parameter
properties, or legacy decorators. Framework + user code stays on
erasable TS via a forthcoming webjs check rule.
The ExperimentalWarning that node:module's stripTypeScriptTypes emits
on first call is suppressed by intercepting process.emitWarning for
just that warning (every other warning passes through unchanged).
…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.
…affolds Update guidance everywhere AI agents and humans read framework rules: root AGENTS.md (invariant #10), agent-docs/typescript.md (full rewrite of TypeScript Feature Support), docs site (typescript and editor-setup pages), scaffold AGENTS.md / CONVENTIONS.md / .cursorrules / .windsurfrules / copilot-instructions.md. Each surface explains: (1) Node 24+ requirement, (2) byte-exact position preservation via module.stripTypeScriptTypes (no sourcemap shipped to the browser), (3) the erasable-TS constraint with worked do-and-dont examples for the four banned patterns (enum, namespace with values, constructor parameter properties, legacy decorators), (4) the esbuild fallback for the rare third-party non-erasable dep, (5) the cost (~3x wire bytes + sourcemap overhead) of taking the fallback path, (6) how erasableSyntaxOnly in tsconfig and the erasable-typescript-only convention check enforce the constraint.
The 'No Build Step' card now mentions byte-exact whitespace replacement and no sourcemap overhead. The 'TypeScript or JSDoc' card calls out the erasable-TS constraint explicitly and notes the erasableSyntaxOnly tsconfig flag + the erasable-typescript-only convention check as the editor-time guard.
Add the runtime-backing detail in framework-engineering-facing
surfaces (not user-facing scaffold templates, which don't need it).
If the framework ever runs on a non-Node runtime (Bun, Deno), Node's
`module.stripTypeScriptTypes` won't be available. The replacement
is the `amaro` package (what Node uses internally, wraps SWC's
WASM TypeScript transform in position-preserving strip-only mode).
Other equivalents: Sucrase (lines preserved, columns not), SWC's
strip mode. Documented in:
- packages/server/src/dev.js (next to the stripTypeScriptTypes
import, where someone doing runtime work will read it)
- agent-docs/typescript.md (deep TS reference)
- root AGENTS.md invariant #10 (the erasable-TS invariant)
- packages/server/AGENTS.md (the dev.js row in the module map)
Also remove the stale esbuild-loader.js row from packages/server/
AGENTS.md (the file was deleted earlier in this branch).
This was referenced May 18, 2026
vivek7405
added a commit
that referenced
this pull request
May 21, 2026
…types feat(server): replace esbuild TS stripping with Node 24+ strip-types
vivek7405
added a commit
that referenced
this pull request
May 21, 2026
…pes model Follow-up to PR #9 (the esbuild-to-Node-strip-types refactor). The prior sweep updated the headline TypeScript and editor-setup docs but missed six other surfaces that still described the old loader hook + Node 20.6 minimum. Updated: - docs/app/docs/getting-started/page.ts (prerequisites + How It Works) - docs/app/docs/deployment/page.ts (lead paragraph + checklist) - docs/app/docs/routing/page.ts (file extension paragraph) - docs/app/docs/ssr/page.ts (TypeScript paragraph after the page example) - agent-docs/typescript.md (Import convention section) - test/dev-handler.test.js (comment on the non-erasable TS test, now correctly describes the hybrid stripper instead of "both paths use esbuild") Each surface now uses the strip-types language already established in docs/app/docs/typescript/page.ts. Sanity-checked: all updated pages serve 200 from a local docs dev server with the new copy.
5 tasks
vivek7405
added a commit
that referenced
this pull request
May 22, 2026
* feat(website): launch the /blog with 11 grounded long-form posts Infrastructure: - `blog/<slug>.md` with frontmatter at the repo root, mirroring the changelog/ shape. Hand-rolled frontmatter parse + markdown renderer in the page handlers (no markdown library, no client runtime). - `website/app/blog/page.ts`: index page listing all posts sorted by date DESC. Layout boundary matches /changelog (max-w-[840px]). - `website/app/blog/[slug]/page.ts`: per-post page with full SEO metadata (title, description, og:title, og:description, og:type, og:url, twitter:card, publishedTime, author, tags). canonical URL per post. Custom-positioned bullets that stay inside the layout via `before:` pseudo-elements. Code blocks with internal padding so long lines do not stick to the left border when they overflow-x. - Nav: `/blog` link added to both desktop and mobile header. - Railway watch path: `/blog/**` added to the website service via the railway agent, so future blog edits trigger redeploys. Posts, each anchored in actual git history / PR descriptions / source-file docstrings (not invented details): - `why-webjs` (origin/thesis, derived from the author's existing post at heyvivek.com; tagline "tiny in size, not in power") - `betting-on-lits-mental-model` (the API parity rationale; 127 lit-ported tests from PR #31's title) - `strip-types-not-esbuild` (the Node 24 stripper migration in PR #9; cache details from packages/server/src/dev.js) - `signals-replaced-setstate` (PR #43, breaking change; TC39 Stage 1 shape; algorithm description from signal.js docstring) - `light-dom-slots-with-full-parity` (PR #8 / #44; polyfill design from slot.js docstring) - `the-naming-saga` (the wjs/webjscli/webjsdev/create-webjs arc from this PR's own development) - `ai-first-is-plumbing` (AGENTS.md + the multi-tool config files + hooks + lint rules, all verifiable in scaffold templates) - `file-based-routing` (router.js JSDoc lists the conventions; same Next.js shape, with the divergences spelled out) - `client-router-turbo-drive-style` (router-client.js docstring + ssr.js's X-Webjs-Have handling) - `why-not-lit-as-a-dependency` (SSR + decorators + the AI-reads-node_modules angle the user surfaced) - `built-ins-auth-session-cookies-cache` (the four-method cache store interface from cache.js's CacheStore typedef, the Remix- shaped Session class, the NextAuth-shaped createAuth()) Typography: - 17px paragraph at 1.8 leading, my-7 spacing. - Title at clamp(36px, 6vw, 56px), more presence on the page. - Description in serif italic at 19px. - Headings at clamp 21-34px with strong vertical rhythm. - Code blocks at 13px monospace with px-6 py-5 padding inside the code element (not the pre) so overflow-x scrolls cleanly. - Footer pad-top + mt-28 so the "All posts" link does not collide with the last paragraph. Markdown supported by the renderer: - # / ## / ### headings (h2 / h3 / h4 in output) - Paragraphs - Bulleted lists with custom-positioned markers - `> ` blockquotes with accent border - ```fenced``` code blocks - Inline: **bold**, *italic*, `code`, [link](url) * fix(blog): switch [slug] page spacing to arbitrary-value classes Three concrete formatting fixes that all stemmed from one root cause: Tailwind named-scale utilities (mt-20, my-8, mt-14, my-7) were NOT landing in the compiled tailwind.css. The dev-server's watcher had not picked them up from the new blog/[slug]/page.ts. So the page rendered with classes that resolved to no CSS at all, which is why the user saw: - Headings sticking to the previous paragraph (no mt-20). - "All posts" footer link colliding with last paragraph. - Code blocks with no vertical breathing room. Fix: switch every spacing utility to its arbitrary-value form (`mt-[80px]` instead of `mt-20`, `my-[28px]` instead of `my-7`, etc.). Arbitrary-value classes get JIT-emitted from the literal token in source, so they compile regardless of whether the named scale has been brought into the build. Also brings the [slug] page's max-width from 760px back up to 840px to match /changelog and /blog index, restoring the layout boundary parity the user pointed out twice. The compiled tailwind.css is gitignored (regenerated at deploy time), so this commit ships only the source change. The classes JIT correctly on the next `tailwindcss` invocation. Bundle of post-grade improvements: - Code block padding moved inside the <code> with px-[24px] py-[20px], so overflow-x preserves padding on both sides. - List items use `before:content-['•']` absolute-positioned markers so bullets stay inside the layout column. - Title at clamp(36px, 6vw, 56px), description in serif italic. - Footer with mt-[128px] pt-[40px] for the "All posts" link. * refactor(website): move blog + changelog logic into modules/, follow webjs's own convention The website was stuffing file-reading, frontmatter parsing, and markdown rendering directly inside `app/blog/page.ts`, `app/blog/[slug]/page.ts`, and `app/changelog/page.ts`. That violates the layout we tell every scaffolded webjs app to follow in AGENTS.md: app/ ROUTING ONLY. Thin route adapters. modules/<feature>/ Feature-scoped queries + utils + types. lib/ App-wide helpers. Dogfooding fix. New layout: website/lib/frontmatter.ts Shared parser (browser-safe). website/modules/blog/queries/ list-posts.server.ts Read all posts, return metadata. get-post.server.ts Read one post by slug, return body. website/modules/blog/utils/render-post.ts Long-form markdown renderer. website/modules/blog/types.ts Post / PostWithBody. website/modules/changelog/queries/ list-entries.server.ts Read all changelog entries. website/modules/changelog/utils/ render-entry.ts Compact-card markdown renderer. pkg-badge.ts Color-coded package pill. website/modules/changelog/types.ts Entry. The route files at `website/app/blog/page.ts`, `website/app/blog/[slug]/page.ts`, and `website/app/changelog/page.ts` are now thin adapters that import from the modules and render the result. None of them do file IO or string-parsing directly. Same routes, same output. Logic moved to where AGENTS.md says it should live. The `'use server'` directive on each query file makes the file source-protected (browser imports get a throw-at-load stub) and RPC-callable (so a client component could in principle import `listPosts` if it needed to, and the dev server would rewrite the import into an RPC stub). For the current pages, both query files are only called server-side from the page's default export. Sharing across the two features: `parseFrontmatter()` is identical for both, so it lives in `website/lib/frontmatter.ts` (lib/ scope because it's cross-feature). The inline-markdown regexes diverge between the two renderers (different code-block sizes, different heading typography), so each module has its own renderer rather than parameterizing a shared one. Two callers, two short implementations, no premature abstraction. * blog(why-webjs): replace 'started' with 'built' in opening * blog(why-webjs): retitle to avoid duplicating the personal-blog title * blog(why-webjs): drop 'small' from the title * blog(why-webjs): reframe around 'wanted this framework, built it for myself' The post leaned hard on critiquing other frameworks (stack traces in minified bundles, convention drift between engineers, etc.). Reframed per the user's direction: the story is "I wanted a framework close to web standards with the Next.js-style DX I enjoy. Could not find one I personally liked. Built one for myself. AI-first followed naturally from building it from scratch in 2025." New shape: - Open with what I wanted (web standards + Next.js DX), the search, the not-finding, the decision to build my own. - "Close to web standards" section explains the platform-first architecture (native web components, lit-shaped public API on top), without comparing other frameworks unfavorably. - "How small that lets the framework be" surfaces the concrete 5-10% of Next.js size claim, with the feature parity list and the explanation: the platform does the heavy lifting (web components, Node 24 strip-types, HTTP/2 multiplex, CSS vars). - "Why AI-first followed naturally" reframes the AI-first content as the consequence of building from scratch in 2025, not the starting motivation. Same content, different positioning. Removed: - "watching AI agents try to write code in those frameworks ..." paragraph that read as a critique of competitors. - "stack traces that pointed at minified bundle positions the agent could not read" line. - "conventions that two engineers would interpret differently" line, which read as a dig. - The "why web components, not React/Vue/Svelte/Solid?" framing. Replaced with "what close-to-standards means" which states the positive case without the comparison. Title and intro keep the AI-era angle for SEO and for the inaugural- post role of why-webjs.md. * blog(lit): consolidate the two lit posts into one The two posts (`betting-on-lits-mental-model.md` and `why-not-lit-as-a-dependency.md`) argued the same point from two angles, with substantial content overlap. Merged the strongest material from both into one post and deleted the redundant file. Kept the `betting-on-lits-mental-model` slug (better SEO surface, nuanced title). Retitled to "Lit-shaped, without depending on lit" to flag the dual angle directly. The merged post is now structured as: 1. The "minimal version: just re-export lit" code, and why I considered it for a week before writing my own runtime. 2. What I wanted to KEEP from lit (the API surface the corpus already knows, with the four-agents experiment as evidence). 3. Why I did NOT depend on lit, broken into four reasons in load- bearing order: a. SSR (the killer, with the four lit-ssr structural limits) b. The decorator + erasable-TypeScript conflict c. The AI-agent-reads-node_modules readability argument d. Fine-grained control over edge cases 4. What an LLM sees when it reads webjs (the code-diff comparison) 5. What the runtime ownership cost (lost lit bug fixes, lost cleverness, ~10 KB size delta) 6. The "what if lit ships SSR + slots tomorrow" hypothetical 7. Not a dig at lit 8. Reading the actual implementation * blog(light-dom-default): add post on why webjs picks light DOM as default Most web-components frameworks default to shadow DOM (lit, Stencil, FAST). webjs flips the default: every component renders in light DOM unless it sets `static shadow = true`. The post walks through six concrete benefits in load-bearing order: 1. Tailwind utility classes apply (the load-bearing one for webjs). 2. CSS stays cache-friendly: external stylesheet hit once by the browser, instead of inline `static styles` shipped per page. 3. document.getElementById, querySelector, closest just work without shadow-piercing. 4. Accessibility behaves the way ARIA + form association specs assume (aria-labelledby across roots, form data carrying light-DOM input names, no formAssociated/ElementInternals ceremony). 5. Playwright / Puppeteer / Web Test Runner selectors work without `>>>` pierce syntax. Agents writing tests reuse the same selectors they write in components. 6. SEO + crawler reach is more reliable in initial HTML. Modern Googlebot handles DSD correctly, but the long tail of crawlers, social-card scrapers, RSS readers, and archival bots is more variable. Light DOM is the lower-variance answer. Counters the "but scoping!" argument by pointing at the two real shadow-DOM use cases (third-party embeds, design-system primitives meant to drop into hostile pages) and notes that Tailwind utilities sidestep the leakage thought experiment for app code. Links to the existing light-dom-slots-with-full-parity post for the slot-projection story, which is what unblocks light DOM as a serious default (most frameworks treat <slot> as a shadow-only feature, webjs ships full parity in both modes). Dated 2025-12-22, slotted between why-webjs (2025-12-15) and light-dom-slots-with-full-parity (2025-12-30) so the foundational decision lands before the slot deep-dive. * blog(light-dom-default): correct the 'shadow DOM is the default' framing Native web components default to LIGHT DOM. If a custom element does not call attachShadow(...), there is no shadow root. Lit picked a different default for its LitElement class (it attaches a shadow root in the constructor unless you override createRenderRoot to return this), and because lit is what most developers and most AI training data treat as canonical web-components style, the perception has shifted toward "shadow DOM is the default." The opening of the post implied the latter. Rewritten to state the platform-level fact accurately: - Native web components default to light DOM. - lit defaults to shadow DOM by attaching a shadow root in LitElement's constructor. - webjs aligns with the platform default, not lit's default. This is also a sharper framing for the rest of the post: the "benefits of light DOM" become "benefits the platform already gives you that lit's default opts out of." Description in the frontmatter updated to match. * blog(light-dom-default): restore Stencil + FAST alongside lit in the framing Web-verified the original claim: lit, Stencil, and FAST all default to shadow DOM. With three independent data points, the framing is sharper than "lit picked a different default" alone: - lit: LitElement attaches a shadow root in its constructor unless you override createRenderRoot to return this. - Stencil: components default to shadow DOM. The stencil generate CLI emits shadow-enabled components and the @component decorator's shadow field defaults to true. - FAST: FASTElement automatically attaches a ShadowRoot and renders the template into it. All three are cited inline with links to their official docs. The reframing is "the three libraries developers actually learn web components from all default to shadow, and that's where the 'shadow is the default' perception comes from. The platform itself does not share that default." That is a stronger argument for webjs's choice than the single-library version. * blog(light-dom-default): correct Stencil's actual default Stencil's @component decorator defaults shadow: false (light DOM). You opt INTO shadow with @component({ shadow: true }). The earlier claim that Stencil "defaults to shadow DOM" conflated the CLI scaffolder default (the `stencil generate` template enables shadow) with the framework's actual decorator default (which does not). Verified by fetching both stencil.io/docs/styling and stencil.io/docs/component: - styling: "To use the Shadow DOM in a Stencil component, you can set the shadow option to true in the component decorator." - component: "If shadow is set to false, the component will not use native shadow DOM encapsulation." Default is false. The post now states the accurate picture: - lit defaults to shadow at the framework level - FAST defaults to shadow at the framework level - Stencil defaults to light at the framework level; the CLI scaffolder is what produces shadow-enabled components The "popular libraries pick shadow" framing is now scoped to lit and FAST. Stencil is called out as the precedent that backs webjs's choice: same underlying default (light), shadow as opt-in. This is actually a stronger argument for webjs than the previous "everyone defaults to shadow" framing, because it shows there is existing precedent in the ecosystem for light-DOM-by-default at the framework level. * blog(light-dom-default): clarify Tailwind is scaffold default, not requirement The post was too Tailwind-centric. Easy to read it as "you need Tailwind to use webjs," which is wrong: the framework is agnostic about the styling story. Tailwind is the scaffold default because it pairs well with the rest of the stack, but vanilla CSS, CSS modules, BEM, hand-written stylesheets, or another utility framework all work the same way. Changes: - Added an explicit note after benefit #1: webjs does NOT require Tailwind, the benefit (external CSS cascades into light-DOM components) is general, Tailwind is just the concrete example in the post. - Benefit #2 ("CSS cache-friendly"): broadened from "tailwind.css" to "an external stylesheet (the scaffold's tailwind.css, or your own app.css, or whatever you write)." - Scoping section: added a mention of BEM / class-prefix discipline as a non-Tailwind way to avoid leakage. Linked to the framework's `light-dom-css-prefix` lint rule that catches unprefixed selectors in vanilla CSS for light-DOM components. - Summary bullets: "Tailwind utility classes apply" -> "External CSS applies without escape hatches: Tailwind, vanilla, CSS modules, BEM, whatever you bring." - Closing paragraph: "light DOM with Tailwind by default" -> "light DOM by default, with Tailwind as the scaffold default but no framework-level requirement to use it." The argument is now framed as light-DOM-vs-shadow, not as Tailwind-evangelism. Tailwind users still see Tailwind-flavored examples throughout, but non-Tailwind users see the framework working for them too. * blog(file-based-routing): strip opening to two options, frame around personal DX The opening listed three options (invent, Rails-shaped, Next.js). Rails was never seriously in the running and the inclusion read as filler. Stripped to two: custom or Next.js. The decision is now framed primarily around the Next.js DX I personally enjoy, with the corpus-priors argument as a secondary reason rather than the load-bearing one. * blog(naming-saga): rewrite 'the user pointed out' to first person The post is bylined by Vivek and written in first-person voice. The 'the user pointed out' phrasing slipped through, treating someone else as the source of the insight. Now reads 'I realized,' matching the rest of the post's voice. Audited the other blog posts for similar third-person 'user' references. The remaining mentions are about end-users of the framework (package.json size, function-wrapping callers, store config etc.) which is the correct use of the word. * blog(index): remove 'written as the project evolves' tagline * blog([slug]): tighten footer spacing before 'All posts' link Reduced mt from 128px to 72px and pt from 40px to 32px, halving the gap between the last paragraph and the bottom 'All posts' link. Earlier value was overcompensating after the user pointed out the link sticking to the paragraph; this lands in the comfortable middle. * blog([slug]): walk back the footer spacing reduction Previous change went from 168px to 104px which the user said was too aggressive. Dialed to 140px (mt-[104px] pt-[36px]), a modest ~17% reduction from the original 168px rather than the 38% cut.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replace the esbuild-based TypeScript stripper with Node 24+'s built-in
module.stripTypeScriptTypes. The new stripper uses whitespace replacement (byte-exact line + column position preservation), so no sourcemap is shipped to the browser. Result: roughly 65-70% reduction in wire bytes for every user.tsfile, plus byte-exact stack traces without sourcemap indirection.For the rare third-party
.tsdependency that uses non-erasable syntax (enum,namespacewith values, parameter properties, legacy decorators), the dev server transparently falls back toesbuild.transformwith an inline sourcemap. esbuild stays in the dependency tree as a runtime fallback and as the bundler forvendor.js(auto-bundling npm packages for the browser), but its scope shrinks substantially.User code is held to erasable TypeScript via the new
erasableSyntaxOnly: trueflag in tsconfig (shipped by the scaffold and the framework's own first-party apps) and a newwebjs checkrule (erasable-typescript-only) that warns if the flag is missing or false.What changed
>=24.0.0across every published package (root, core, server, cli, ui, ts-plugin) plus the first-party apps (examples/blog, docs, website). Node 24 made strip-types default-on with no experimental warning.packages/server/src/esbuild-loader.jsdeleted. Server-side.tsimports work natively via Node's default strip-types. Nomodule.register()hook needed.packages/server/src/dev.js: newstripTs(source, abs)helper. Primary path ismodule.stripTypeScriptTypes. CatchesERR_UNSUPPORTED_TYPESCRIPT_SYNTAXand falls back to esbuild + inline sourcemap. The one-shot ExperimentalWarning is suppressed at startup.packages/server/src/check.js: newerasable-typescript-onlyrule. Loads the user'stsconfig.json, warns whencompilerOptions.erasableSyntaxOnlyis missing or false. Lighter than parsing TS ourselves; the actual checking is delegated totsc.packages/cli/lib/create.js: scaffold writeserasableSyntaxOnly: trueinto every new app's tsconfig.Wire-byte impact, measured
examples/blog/app/page.ts(4,371 bytes source) round-trip through the dev server:For a typical webjs app with ~20 first-party
.tsfiles, this saves ~70 KB brotli on a cold page load.Test plan
ui-stateful/ui-overlayones that fail on main. Zero new regressions.WEBJS_E2E=1): 46/48 pass. The 2 failures are pre-existing on main (layoutdata-layoutwrapper, dynamic-route head). Zero new regressions.webjs checkpasses against examples/blog (with the newerasable-typescript-onlyrule active).stripTypeScriptTypes(no sourcemap, byte-exact positions); a temporaryenumfile routed correctly through the esbuild fallback (with inline sourcemap).Breaking changes
engines: ">=20"but usedmodule.register()for the loader hook. Now native strip-types replaces the loader, and Node 24 is the floor where it ships default-on without an experimental warning.erasableSyntaxOnly: trueis now the recommended tsconfig setting. Apps that previously usedenum,namespace, parameter properties, or legacy decorators will either need to switch to erasable equivalents (recommended) or accept the esbuild fallback path (slower, ~3x wire bytes).