Skip to content

Commit 29f1645

Browse files
committed
feat(server): hybrid TS stripper (Node strip-types + esbuild fallback)
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).
1 parent 32de471 commit 29f1645

3 files changed

Lines changed: 77 additions & 223 deletions

File tree

packages/server/src/dev.js

Lines changed: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,30 @@ import { stat, readFile } from 'node:fs/promises';
33
import { createHash } from 'node:crypto';
44
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
55
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
6-
import { createRequire, register } from 'node:module';
6+
import { createRequire, stripTypeScriptTypes } from 'node:module';
77
import { 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

2331
import { buildRouteTable, matchPage, matchApi } from './router.js';
2432
import { 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
*/
7798
const 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
*/
775831
async 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',

packages/server/src/esbuild-loader.js

Lines changed: 0 additions & 66 deletions
This file was deleted.

test/esbuild-loader.test.js

Lines changed: 0 additions & 129 deletions
This file was deleted.

0 commit comments

Comments
 (0)