Repository: un-ts/synckit (ts)

Issue:
Hang in specific use cases
When I was using Vitest to test my ESLint rules, I encountered a problem where the test cases were hanging for a long time.

When my worker file is written in TypeScript, it hangs. I have tried specifying `tsRunner` as `tsx` and `esbuild-register` but both are the same.

If I write my worker using JavaScript, it works very well.

Here is a minimal reproduction:

https://stackblitz.com/edit/vitest-dev-vitest-ggrsdgek?file=src%2Frules%2Fdummy.ts

Pull request description:
feat: add new ts runner `node` for Node22+
Possibly also fixes #198 

As of Node 22 (recently LTS), we have native support for type stripping, making runners obsolete for most use cases.
This PR adds the "node" runner, which is now the default over ts-node.

Let me know what you think 👍 

Patch:
diff --git a/.changeset/good-walls-brush.md b/.changeset/good-walls-brush.md
new file mode 100644
index 0000000..df38d26
--- /dev/null
+++ b/.changeset/good-walls-brush.md
@@ -0,0 +1,15 @@
+---
+"synckit": minor
+---
+
+feat: add support for `--experimental-strip-types`
+
+Introducing the `node` runner, which will replace `ts-node` as the new default:
+
+- when running on Node 22 with the `--experimental-strip-types`
+  flag enabled via `NODE_OPTIONS` env or cli args
+- or when running on Node 23+ without `--no-experimental-strip-types`
+  flag enabled via `NODE_OPTIONS` env or cli args
+
+An error will be thrown when attempting to run with `node` on unsupported versions (<22).
+On these versions, the default runner remains `ts-node` when available.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5ecccf6..b7b5ca2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,6 +57,5 @@ jobs:
 
       - name: Codecov
         uses: codecov/codecov-action@v5
-        if: ${{ matrix.node == 18 || matrix.node == 18.18 }}
         with:
           token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/README.md b/README.md
index 153ae78..d3766fe 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,8 @@ Perform async work synchronously in Node.js using `worker_threads` with first-cl
   - [Options](#options)
   - [Envs](#envs)
   - [TypeScript](#typescript)
-    - [`ts-node`](#ts-node)
+    - [`node` (Default, Node 23+)](#node-default-node-23)
+    - [`ts-node` (Default)](#ts-node-default)
     - [`esbuild-register`](#esbuild-register)
     - [`esbuild-runner`](#esbuild-runner)
     - [`swc`](#swc)
@@ -53,7 +54,7 @@ import { createSyncFn } from 'synckit'
 
 // the worker path must be absolute
 const syncFn = createSyncFn(require.resolve('./worker'), {
-  tsRunner: 'tsx', // optional, can be `'ts-node' | 'esbuild-register' | 'esbuild-runner' | 'tsx'`
+  tsRunner: 'tsx', // optional, can be `'node' | 'ts-node' | 'esbuild-register' | 'esbuild-runner' | 'tsx'`
 })
 
 // do whatever you want, you will get the result synchronously!
@@ -126,9 +127,17 @@ export interface GlobalShim {
 
 ### TypeScript
 
-#### `ts-node`
+#### `node` (Default, Node 23+)
 
-If you want to use `ts-node` for worker file (a `.ts` file), it is supported out of box!
+On recent Node versions, you may select this runner to execute your worker file (a `.ts` file) in the native runtime.
+
+As of Node v23, this feature is supported out of the box. To enable it in the current LTS, you can pass the [`--experimental-strip-types`](https://nodejs.org/docs/latest-v22.x/api/typescript.html#type-stripping) flag to the process. Visit the [documentation](https://nodejs.org/docs/latest/api/typescript.html#type-stripping) to learn more.
+
+When `synckit` detects the process to be running with this flag, it will execute the worker file with the `node` runner by default.
+
+#### `ts-node` (Default)
+
+Prior to Node v23, you may want to use `ts-node` to execute your worker file (a `.ts` file).
 
 If you want to use a custom tsconfig as project instead of default `tsconfig.json`, use `TS_NODE_PROJECT` env. Please view [ts-node](https://github.com/TypeStrong/ts-node#tsconfig) for more details.
 
diff --git a/src/index.ts b/src/index.ts
index 9b991f7..e0035c6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -33,6 +33,8 @@ const INT32_BYTES = 4
 export * from './types.js'
 
 export const TsRunner = {
+  // https://nodejs.org/docs/latest/api/typescript.html#type-stripping
+  Node: 'node',
   // https://github.com/TypeStrong/ts-node
   TsNode: 'ts-node',
   // https://github.com/egoist/esbuild-register
@@ -55,7 +57,23 @@ const {
   SYNCKIT_TS_RUNNER,
 } = process.env
 
-const IS_NODE_20 = Number(process.versions.node.split('.')[0]) >= 20
+export const MTS_SUPPORTED_NODE_VERSION = 16
+export const LOADER_SUPPORTED_NODE_VERSION = 20
+export const STRIP_TYPES_DEFAULT_NODE_VERSION = 23
+export const STRIP_TYPES_SUPPORTED_NODE_VERSION = 22
+
+const NODE_VERSION = Number.parseFloat(process.versions.node)
+const STRIP_TYPES_FLAG = '--experimental-strip-types'
+const NO_STRIP_TYPES_FLAG = '--no-experimental-strip-types'
+const IS_TYPE_STRIPPING_ENABLED =
+  (NODE_VERSION >= STRIP_TYPES_DEFAULT_NODE_VERSION &&
+    !(
+      NODE_OPTIONS?.includes(NO_STRIP_TYPES_FLAG) ||
+      process.argv.includes(NO_STRIP_TYPES_FLAG)
+    )) ||
+  (NODE_VERSION >= STRIP_TYPES_SUPPORTED_NODE_VERSION &&
+    (NODE_OPTIONS?.includes(STRIP_TYPES_FLAG) ||
+      process.argv.includes(STRIP_TYPES_FLAG)))
 
 export const DEFAULT_TIMEOUT = SYNCKIT_TIMEOUT ? +SYNCKIT_TIMEOUT : undefined
 
@@ -80,8 +98,6 @@ export const DEFAULT_GLOBAL_SHIMS_PRESET: GlobalShim[] = [
   },
 ]
 
-export const MTS_SUPPORTED_NODE_VERSION = 16
-
 let syncFnCache: Map<string, AnyFn> | undefined
 
 export interface SynckitOptions {
@@ -205,11 +221,27 @@ const setupTsRunner = (
       }
     }
 
-    if (tsRunner == null && isPkgAvailable(TsRunner.TsNode)) {
-      tsRunner = TsRunner.TsNode
+    if (tsRunner == null) {
+      if (IS_TYPE_STRIPPING_ENABLED) {
+        tsRunner = TsRunner.Node
+      } else if (isPkgAvailable(TsRunner.TsNode)) {
+        tsRunner = TsRunner.TsNode
+      }
     }
 
     switch (tsRunner) {
+      case TsRunner.Node: {
+        if (NODE_VERSION < STRIP_TYPES_SUPPORTED_NODE_VERSION) {
+          throw new Error(
+            'type stripping is not supported in this node version',
+          )
+        }
+        execArgv =
+          NODE_VERSION >= STRIP_TYPES_DEFAULT_NODE_VERSION
+            ? execArgv.filter(arg => arg !== NO_STRIP_TYPES_FLAG)
+            : [STRIP_TYPES_FLAG, ...execArgv]
+        break
+      }
       case TsRunner.TsNode: {
         if (tsUseEsm) {
           if (!execArgv.includes('--loader')) {
@@ -283,7 +315,7 @@ const setupTsRunner = (
         // https://github.com/un-ts/synckit/issues/123
         resolvedPnpLoaderPath = pathToFileURL(pnpLoaderPath).toString()
 
-        if (!IS_NODE_20) {
+        if (NODE_VERSION < LOADER_SUPPORTED_NODE_VERSION) {
           execArgv = [
             '--experimental-loader',
             resolvedPnpLoaderPath,
@@ -452,8 +484,7 @@ function startWorkerThread<R, T extends AnyAsyncFn<R>>(
 
   if (/\.[cm]ts$/.test(finalWorkerPath)) {
     const isTsxSupported =
-      !tsUseEsm ||
-      Number.parseFloat(process.versions.node) >= MTS_SUPPORTED_NODE_VERSION
+      !tsUseEsm || NODE_VERSION >= MTS_SUPPORTED_NODE_VERSION
     /* istanbul ignore if */
     if (!finalTsRunner) {
       throw new Error('No ts runner specified, ts worker path is not supported')
@@ -604,7 +635,7 @@ export function runAsWorker<
 
   const { workerPort, sharedBuffer, pnpLoaderPath } = workerData as WorkerData
 
-  if (pnpLoaderPath && IS_NODE_20) {
+  if (pnpLoaderPath && NODE_VERSION >= LOADER_SUPPORTED_NODE_VERSION) {
     module.register(pnpLoaderPath)
   }
 


Test patch:
diff --git a/test/ts-runner.spec.ts b/test/ts-runner.spec.ts
index 813fe5b..97de797 100644
--- a/test/ts-runner.spec.ts
+++ b/test/ts-runner.spec.ts
@@ -7,7 +7,12 @@ import { jest } from '@jest/globals'
 import { _dirname, nodeVersion, tsUseEsmSupported } from './helpers.js'
 import type { AsyncWorkerFn } from './types.js'
 
-import { MTS_SUPPORTED_NODE_VERSION, TsRunner } from 'synckit'
+import {
+  MTS_SUPPORTED_NODE_VERSION,
+  STRIP_TYPES_DEFAULT_NODE_VERSION,
+  STRIP_TYPES_SUPPORTED_NODE_VERSION,
+  TsRunner,
+} from 'synckit'
 
 beforeEach(() => {
   jest.resetModules()
@@ -130,6 +135,44 @@ test(TsRunner.TSX, async () => {
   expect(syncFn(5)).toBe(5)
 })
 
+test(TsRunner.Node, async () => {
+  const { createSyncFn } = await import('synckit')
+
+  if (nodeVersion < STRIP_TYPES_SUPPORTED_NODE_VERSION) {
+    // eslint-disable-next-line jest/no-conditional-expect
+    expect(() =>
+      createSyncFn<AsyncWorkerFn>(workerMtsPath, {
+        tsRunner: TsRunner.Node,
+      }),
+    ).toThrow('type stripping is not supported in this node version')
+    return
+  }
+
+  let syncFn = createSyncFn<AsyncWorkerFn>(workerJsPath, {
+    tsRunner:
+      nodeVersion >= STRIP_TYPES_DEFAULT_NODE_VERSION
+        ? undefined
+        : TsRunner.Node,
+  })
+  expect(syncFn(1)).toBe(1)
+  expect(syncFn(2)).toBe(2)
+  expect(syncFn(5)).toBe(5)
+
+  if (!tsUseEsmSupported) {
+    return
+  }
+
+  syncFn = createSyncFn<AsyncWorkerFn>(workerMtsPath, {
+    tsRunner:
+      nodeVersion >= STRIP_TYPES_DEFAULT_NODE_VERSION
+        ? undefined
+        : TsRunner.Node,
+  })
+  expect(syncFn(1)).toBe(1)
+  expect(syncFn(2)).toBe(2)
+  expect(syncFn(5)).toBe(5)
+})
+
 test('unknown ts runner', async () => {
   const { createSyncFn } = await import('synckit')