@@ -3,22 +3,30 @@ import { stat, readFile } from 'node:fs/promises';
33import { createHash } from 'node:crypto' ;
44import { createGzip , createBrotliCompress , constants as zlibConstants } from 'node:zlib' ;
55import { join , extname , resolve , dirname , relative , sep } from 'node:path' ;
6- import { createRequire , register } from 'node:module' ;
6+ import { createRequire , stripTypeScriptTypes } from 'node:module' ;
77import { fileURLToPath , pathToFileURL } from 'node:url' ;
88
9- // Route every server-side `.ts` import through esbuild: same transformer
10- // as the dev server uses for browser-bound modules. Keeps SSR and hydration
11- // output identical and supports the full TS feature set (enums, decorators,
12- // parameter properties) that Node's built-in stripper rejects.
9+ // Server-side `.ts` imports are handled natively by Node 24+'s default
10+ // type-stripping (`process.features.typescript === 'strip'`). No loader
11+ // hook required. The browser-bound TypeScript request handler uses
12+ // `module.stripTypeScriptTypes` for the same transform, so SSR and
13+ // hydration produce identical JS.
1314//
14- // Registered before any user-app import. Idempotent across restarts.
15- let _esbuildLoaderRegistered = false ;
16- function registerEsbuildLoader ( ) {
17- if ( _esbuildLoaderRegistered ) return ;
18- _esbuildLoaderRegistered = true ;
19- register ( './esbuild-loader.js' , import . meta. url ) ;
20- }
21- registerEsbuildLoader ( ) ;
15+ // Suppress the one-shot ExperimentalWarning that Node prints the
16+ // first time `stripTypeScriptTypes` is called. The API is committed
17+ // per Node 24's release notes; the warning is a holdover. We keep
18+ // every other warning intact.
19+ const _origEmitWarning = process . emitWarning . bind ( process ) ;
20+ process . emitWarning = function ( warning , type , code , ctor ) {
21+ const msg = warning && warning . message ? warning . message : String ( warning ) ;
22+ if (
23+ ( type === 'ExperimentalWarning' || ( warning && warning . name === 'ExperimentalWarning' ) ) &&
24+ msg . includes ( 'stripTypeScriptTypes' )
25+ ) {
26+ return ;
27+ }
28+ return _origEmitWarning ( warning , type , code , ctor ) ;
29+ } ;
2230
2331import { buildRouteTable , matchPage , matchApi } from './router.js' ;
2432import { ssrPage , ssrNotFound } from './ssr.js' ;
@@ -68,10 +76,23 @@ const MIME = {
6876} ;
6977
7078/**
71- * Cache of esbuild-transformed `.ts` / `.mts` source.
72- * Keyed by absolute file path; entries expire when mtime changes.
79+ * Cache of stripped `.ts` / `.mts` source.
80+ * Keyed by absolute file path. Entries expire when mtime changes.
7381 * Capped at 500 entries to prevent unbounded memory growth in
7482 * long-running production servers.
83+ *
84+ * Primary stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
85+ * Position-preserving whitespace replacement. No sourcemap is
86+ * emitted because every (line, column) maps to itself in the source.
87+ *
88+ * Fallback stripper: `esbuild.transform`. Triggered only when the
89+ * primary path throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` (the file
90+ * uses `enum`, `namespace`, parameter properties, or legacy
91+ * decorators). Emits an inline sourcemap so DevTools can still
92+ * resolve source positions for the regenerated JS. Mostly fires for
93+ * third-party `.ts` files; user code is enforced erasable by
94+ * `webjs check`.
95+ *
7596 * @type {Map<string, { mtimeMs: number, code: string, map: string | null }> }
7697 */
7798const TS_CACHE_MAX = 500 ;
@@ -765,15 +786,49 @@ async function exists(p) {
765786}
766787
767788/**
768- * Serve a `.ts` / `.mts` source file as JavaScript. Types are stripped via
769- * esbuild's transform() (microseconds per file). Result is cached by mtime
770- * so subsequent requests are instant; a file edit invalidates naturally.
789+ * Strip TypeScript types from `source`, using Node's built-in
790+ * `module.stripTypeScriptTypes` first (whitespace replacement,
791+ * position-preserving, no sourcemap needed) and falling back to
792+ * esbuild for files using non-erasable syntax (`enum`, `namespace`,
793+ * parameter properties, legacy decorators).
794+ *
795+ * The framework's own code and the user's app code are kept on
796+ * erasable TS by the `erasable-typescript-only` convention check.
797+ * The fallback exists for third-party `.ts` files that the runtime
798+ * occasionally needs to serve.
799+ *
800+ * @param {string } source
801+ * @param {string } abs
802+ * @returns {Promise<string> }
803+ */
804+ async function stripTs ( source , abs ) {
805+ try {
806+ return stripTypeScriptTypes ( source ) ;
807+ } catch ( err ) {
808+ if ( err && err . code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' ) {
809+ const { transform : esbuild } = await loadEsbuild ( ) ;
810+ const r = await esbuild ( source , {
811+ loader : 'ts' ,
812+ format : 'esm' ,
813+ target : 'es2022' ,
814+ sourcemap : 'inline' ,
815+ sourcefile : abs ,
816+ } ) ;
817+ return r . code ;
818+ }
819+ throw err ;
820+ }
821+ }
822+
823+ /**
824+ * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
825+ * Result is cached by mtime so subsequent requests are instant; a
826+ * file edit invalidates naturally.
771827 *
772828 * @param {string } abs
773829 * @param {boolean } dev
774830 */
775831async function tsResponse ( abs , dev ) {
776- const { transform : esbuild } = await loadEsbuild ( ) ;
777832 const st = await stat ( abs ) ;
778833 const cached = TS_CACHE . get ( abs ) ;
779834 if ( cached && cached . mtimeMs === st . mtimeMs ) {
@@ -785,20 +840,14 @@ async function tsResponse(abs, dev) {
785840 } ) ;
786841 }
787842 const source = await readFile ( abs , 'utf8' ) ;
788- const result = await esbuild ( source , {
789- loader : abs . endsWith ( '.mts' ) ? 'ts' : 'ts' ,
790- format : 'esm' ,
791- target : 'es2022' ,
792- sourcemap : 'inline' ,
793- sourcefile : abs ,
794- } ) ;
843+ const code = await stripTs ( source , abs ) ;
795844 // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
796845 if ( TS_CACHE . size >= TS_CACHE_MAX ) {
797846 const oldest = TS_CACHE . keys ( ) . next ( ) . value ;
798847 TS_CACHE . delete ( oldest ) ;
799848 }
800- TS_CACHE . set ( abs , { mtimeMs : st . mtimeMs , code : result . code , map : null } ) ;
801- return new Response ( result . code , {
849+ TS_CACHE . set ( abs , { mtimeMs : st . mtimeMs , code, map : null } ) ;
850+ return new Response ( code , {
802851 headers : {
803852 'content-type' : 'application/javascript; charset=utf-8' ,
804853 'cache-control' : dev ? 'no-cache' : 'public, max-age=3600' ,
0 commit comments