Repository: un-ts/synckit (ts)

Issue:
feat: support flag precendence
The following should both pass check.

```sh
node --experimental-transform-types —-no-experimental-strip-types index.ts
```

```sh
NODE_OPTIONS='--no-experimental-strip-types' node --experimental-transform-types index.ts
```


Pull request description:
feat: support `node` runner by default for Node 22.6+
close #212

Patch:
diff --git a/.changeset/great-phones-poke.md b/.changeset/great-phones-poke.md
new file mode 100644
index 0000000..b5e2d47
--- /dev/null
+++ b/.changeset/great-phones-poke.md
@@ -0,0 +1,5 @@
+---
+"synckit": minor
+---
+
+feat: support `node` runner by default for Node 22.6+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b5b76f3..a983b81 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,6 +20,7 @@ jobs:
           - 18.18
           - 20
           - 22
+          - 23
         os:
           - macos-latest
           - ubuntu-latest
diff --git a/README.md b/README.md
index 413e16b..8327a78 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Perform async work synchronously in Node.js using `worker_threads` with first-cl
   - [Options](#options)
   - [Envs](#envs)
   - [TypeScript](#typescript)
-    - [`node` (Default, Node 23.6+)](#node-default-node-236)
+    - [`node` (Default, Node 22.6+)](#node-default-node-226)
     - [`bun` (Default, Bun)](#bun-default-bun)
     - [`ts-node` (Default)](#ts-node-default)
     - [`esbuild-register`](#esbuild-register)
@@ -128,13 +128,13 @@ export interface GlobalShim {
 
 ### TypeScript
 
-#### `node` (Default, Node 23.6+)
+#### `node` (Default, Node 22.6+)
 
 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.6, 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.
+As of `Node` v23.6, this feature is supported out of the box. For `Node` `>=22.6 <23.6`, this feature is supported via `--experimental-strip-types` flag. 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.
+When `synckit` detects the process to be running with supported `Node` versions (>=22.6), it will execute the worker file with the `node` runner by default, you can disable this behavior by setting `--no-experimental-strip-types` flag via `NODE_OPTIONS` env or cli arg.
 
 #### `bun` (Default, Bun)
 
@@ -146,7 +146,7 @@ In this case, `synckit` doesn't do anything to the worker itself, it just passes
 
 #### `ts-node` (Default)
 
-Prior to Node v23.6, you may want to use `ts-node` to execute your worker file (a `.ts` file).
+Prior to Node v22.6, 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/benchmarks/benchmark.cjs b/benchmarks/benchmark.cjs
index 44cf4d1..1683268 100644
--- a/benchmarks/benchmark.cjs
+++ b/benchmarks/benchmark.cjs
@@ -6,13 +6,19 @@ const RUN_TIMES = +(process.env.RUN_TIMES || 1000)
 
 /**
  * @param {string} name
- * @typedef {{ loadTime:number,runTime:number, totalTime:number }} PerfResult
+ * @typedef {{ loadTime: number, runTime: number, totalTime: number } | void} PerfResult
  * @returns {PerfResult} Perf result
  */
 const perfCase = name => {
   const loadStartTime = performance.now()
 
-  const syncFn = require(`./${name}.cjs`)
+  let syncFn
+
+  try {
+    syncFn = require(`./${name}.cjs`)
+  } catch {
+    return
+  }
 
   const loadTime = performance.now() - loadStartTime
 
@@ -49,23 +55,27 @@ class Benchmark {
     const _baseKey = keys[0]
     const baseKey = kebabCase(_baseKey)
 
-    const perfTypes = /** @type {Array<keyof PerfResult>} */ ([
+    const perfTypes = /** @type {const} */ ([
       'loadTime',
       'runTime',
       'totalTime',
     ])
 
     for (const perfType of perfTypes) {
-      const basePerf = perfResults[_baseKey][perfType]
+      const basePerf = perfResults[_baseKey]?.[perfType]
       this[perfType] = keys.reduce((acc, key) => {
         const perfResult = perfResults[key]
+        const prefTime = perfResult?.[perfType]
         key = kebabCase(key)
         return Object.assign(acc, {
-          [key]: perfResult[perfType].toFixed(2) + 'ms',
+          [key]: prefTime == null ? 'N/A' : prefTime.toFixed(2) + 'ms',
           ...(key === baseKey
             ? null
             : {
-                [`perf ${key}`]: this.perf(basePerf, perfResult[perfType]),
+                [`perf ${key}`]:
+                  basePerf == null || prefTime == null
+                    ? 'N/A'
+                    : this.perf(basePerf, prefTime),
               }),
         })
       }, {})
diff --git a/benchmarks/benchmark.js b/benchmarks/benchmark.js
index 46ecc2d..4e15f3c 100644
--- a/benchmarks/benchmark.js
+++ b/benchmarks/benchmark.js
@@ -9,17 +9,23 @@ const __filename = fileURLToPath(import.meta.url)
 
 /**
  * @param {string} name
- * @typedef {{ loadTime:number,runTime:number, totalTime:number }} PerfResult
+ * @typedef {{ loadTime: number, runTime: number, totalTime: number } | void} PerfResult
  * @returns {Promise<PerfResult>} Perf result
  */
 const perfCase = async name => {
   const loadStartTime = performance.now()
 
-  const { default: syncFn } = await import(
-    `./${name}.${
-      name === 'synckit' || name === 'make-synchronized' ? 'js' : 'cjs'
-    }`
-  )
+  let syncFn
+
+  try {
+    ;({ default: syncFn } = await import(
+      `./${name}.${
+        name === 'synckit' || name === 'make-synchronized' ? 'js' : 'cjs'
+      }`
+    ))
+  } catch {
+    return
+  }
 
   const loadTime = performance.now() - loadStartTime
 
@@ -56,23 +62,27 @@ class Benchmark {
     const _baseKey = keys[0]
     const baseKey = kebabCase(_baseKey)
 
-    const perfTypes = /** @type {Array<keyof PerfResult>} */ ([
+    const perfTypes = /** @type {const} */ ([
       'loadTime',
       'runTime',
       'totalTime',
     ])
 
     for (const perfType of perfTypes) {
-      const basePerf = perfResults[_baseKey][perfType]
+      const basePerf = perfResults[_baseKey]?.[perfType]
       this[perfType] = keys.reduce((acc, key) => {
         const perfResult = perfResults[key]
+        const perfTime = perfResult?.[perfType]
         key = kebabCase(key)
         return Object.assign(acc, {
-          [key]: perfResult[perfType].toFixed(2) + 'ms',
+          [key]: perfTime == null ? 'N/A' : perfTime.toFixed(2) + 'ms',
           ...(key === baseKey
             ? null
             : {
-                [`perf ${key}`]: this.perf(basePerf, perfResult[perfType]),
+                [`perf ${key}`]:
+                  basePerf == null || perfTime == null
+                    ? 'N/A'
+                    : this.perf(basePerf, perfTime),
               }),
         })
       }, {})
diff --git a/src/index.ts b/src/index.ts
index b46f166..95e8e44 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -52,24 +52,70 @@ export const TsRunner = {
 export type TsRunner = ValueOf<typeof TsRunner>
 
 const {
-  NODE_OPTIONS,
-  SYNCKIT_EXEC_ARGV,
+  NODE_OPTIONS: NODE_OPTIONS_ = '',
+  SYNCKIT_EXEC_ARGV = '',
   SYNCKIT_GLOBAL_SHIMS,
   SYNCKIT_TIMEOUT,
   SYNCKIT_TS_RUNNER,
 } = process.env
 
-export const MTS_SUPPORTED_NODE_VERSION = 16
-export const LOADER_SUPPORTED_NODE_VERSION = 20
-export const TYPESCRIPT_DEFAULT_NODE_VERSION = 23.6
+export const MTS_SUPPORTED_NODE_VERSION = '16'
+export const LOADER_SUPPORTED_NODE_VERSION = '20'
 
-const NODE_TYPESCRIPT = process.features.typescript
-const NODE_VERSION = Number.parseFloat(process.versions.node)
+// https://nodejs.org/docs/latest-v22.x/api/typescript.html#type-stripping
+export const STRIP_TYPES_NODE_VERSION = '22.6'
+
+// https://nodejs.org/docs/latest-v23.x/api/typescript.html#modules-typescript
+export const TRANSFORM_TYPES_NODE_VERSION = '22.7'
+
+// https://nodejs.org/docs/latest-v22.x/api/process.html#processfeaturestypescript
+export const FEATURE_TYPESCRIPT_NODE_VERSION = '22.10'
+
+// https://nodejs.org/docs/latest-v23.x/api/typescript.html#type-stripping
+export const DEFAULT_TYPES_NODE_VERSION = '23.6'
+
+export const STRIP_TYPES_FLAG = '--experimental-strip-types'
+export const TRANSFORM_TYPES_FLAG = '--experimental-transform-types'
+export const NO_STRIP_TYPES_FLAG = '--no-experimental-strip-types'
+
+const NODE_OPTIONS = NODE_OPTIONS_.split(/\s+/)
+
+const hasFlag = (flag: string) =>
+  NODE_OPTIONS.includes(flag) || process.argv.includes(flag)
+
+const parseVersion = (version: string) =>
+  version.split('.').map(Number.parseFloat)
+
+// A naive implementation of semver comparison
+export const compareVersion = (version1: string, version2: string) => {
+  const versions1 = parseVersion(version1)
+  const versions2 = parseVersion(version2)
+  const length = Math.max(versions1.length, versions2.length)
+  for (let i = 0; i < length; i++) {
+    const v1 = versions1[i] || 0
+    const v2 = versions2[i] || 0
+    if (v1 > v2) {
+      return 1
+    }
+    if (v1 < v2) {
+      return -1
+    }
+  }
+  return 0
+}
+
+export const NODE_VERSION = process.versions.node
+
+const NO_STRIP_TYPES = // >=
+  compareVersion(NODE_VERSION, FEATURE_TYPESCRIPT_NODE_VERSION) >= 0
+    ? process.features.typescript === false
+    : hasFlag(NO_STRIP_TYPES_FLAG) &&
+      !hasFlag(STRIP_TYPES_FLAG) &&
+      !hasFlag(TRANSFORM_TYPES_FLAG)
 
 export const DEFAULT_TIMEOUT = SYNCKIT_TIMEOUT ? +SYNCKIT_TIMEOUT : undefined
 
-/* istanbul ignore next */
-export const DEFAULT_EXEC_ARGV = SYNCKIT_EXEC_ARGV?.split(',') || []
+export const DEFAULT_EXEC_ARGV = SYNCKIT_EXEC_ARGV.split(',')
 
 export const DEFAULT_TS_RUNNER = SYNCKIT_TS_RUNNER as TsRunner | undefined
 
@@ -209,10 +255,26 @@ const setupTsRunner = (
       }
     }
 
+    const stripTypesIndex = execArgv.indexOf(STRIP_TYPES_FLAG)
+    const transformTypesIndex = execArgv.indexOf(TRANSFORM_TYPES_FLAG)
+    const noStripTypesIndex = execArgv.indexOf(NO_STRIP_TYPES_FLAG)
+
+    const execArgvNoStripTypes =
+      noStripTypesIndex > stripTypesIndex ||
+      noStripTypesIndex > transformTypesIndex
+
+    const noStripTypes =
+      execArgvNoStripTypes ||
+      (stripTypesIndex === -1 && transformTypesIndex === -1 && NO_STRIP_TYPES)
+
     if (tsRunner == null) {
       if (process.versions.bun) {
         tsRunner = TsRunner.Bun
-      } else if (NODE_TYPESCRIPT) {
+      } else if (
+        !noStripTypes &&
+        // >=
+        compareVersion(NODE_VERSION, STRIP_TYPES_NODE_VERSION) >= 0
+      ) {
         tsRunner = TsRunner.Node
       } else if (isPkgAvailable(TsRunner.TsNode)) {
         tsRunner = TsRunner.TsNode
@@ -224,17 +286,36 @@ const setupTsRunner = (
         break
       }
       case TsRunner.Node: {
-        if (!NODE_TYPESCRIPT) {
+        // <
+        if (compareVersion(NODE_VERSION, STRIP_TYPES_NODE_VERSION) < 0) {
           throw new Error(
             'type stripping is not supported in this node version',
           )
         }
-        execArgv = [
-          '--experimental-transform-types',
-          ...(NODE_VERSION >= TYPESCRIPT_DEFAULT_NODE_VERSION
-            ? execArgv.filter(arg => arg !== '--no-experimental-strip-types')
-            : execArgv),
-        ]
+
+        if (noStripTypes) {
+          throw new Error('type stripping is disabled explicitly')
+        }
+
+        // >=
+        if (compareVersion(NODE_VERSION, DEFAULT_TYPES_NODE_VERSION) >= 0) {
+          break
+        }
+
+        if (
+          // >=
+          compareVersion(NODE_VERSION, TRANSFORM_TYPES_NODE_VERSION) >= 0 &&
+          !execArgv.includes(TRANSFORM_TYPES_FLAG)
+        ) {
+          execArgv = [TRANSFORM_TYPES_FLAG, ...execArgv]
+        } else if (
+          // >=
+          compareVersion(NODE_VERSION, STRIP_TYPES_NODE_VERSION) >= 0 &&
+          !execArgv.includes(STRIP_TYPES_FLAG)
+        ) {
+          execArgv = [STRIP_TYPES_FLAG, ...execArgv]
+        }
+
         break
       }
       case TsRunner.TsNode: {
@@ -286,7 +367,6 @@ const setupTsRunner = (
 
   /* istanbul ignore if -- https://github.com/facebook/jest/issues/5274 */
   if (process.versions.pnp) {
-    const nodeOptions = NODE_OPTIONS?.split(/\s+/)
     let pnpApiPath: string | undefined
     try {
       /** @see https://github.com/facebook/jest/issues/9543 */
@@ -294,10 +374,10 @@ const setupTsRunner = (
     } catch {}
     if (
       pnpApiPath &&
-      !nodeOptions?.some(
+      !NODE_OPTIONS.some(
         (option, index) =>
           ['-r', '--require'].includes(option) &&
-          pnpApiPath === cjsRequire.resolve(nodeOptions[index + 1]),
+          pnpApiPath === cjsRequire.resolve(NODE_OPTIONS[index + 1]),
       ) &&
       !execArgv.includes(pnpApiPath)
     ) {
@@ -309,7 +389,8 @@ const setupTsRunner = (
         // https://github.com/un-ts/synckit/issues/123
         resolvedPnpLoaderPath = pathToFileURL(pnpLoaderPath).toString()
 
-        if (NODE_VERSION < LOADER_SUPPORTED_NODE_VERSION) {
+        // <
+        if (compareVersion(NODE_VERSION, LOADER_SUPPORTED_NODE_VERSION) < 0) {
           execArgv = [
             '--experimental-loader',
             resolvedPnpLoaderPath,
@@ -482,7 +563,9 @@ function startWorkerThread<T extends AnyFn, R = Awaited<ReturnType<T>>>(
 
   if (/\.[cm]ts$/.test(finalWorkerPath)) {
     const isTsxSupported =
-      !tsUseEsm || NODE_VERSION >= MTS_SUPPORTED_NODE_VERSION
+      !tsUseEsm ||
+      // >=
+      compareVersion(NODE_VERSION, MTS_SUPPORTED_NODE_VERSION) >= 0
     /* istanbul ignore if */
     if (!finalTsRunner) {
       throw new Error('No ts runner specified, ts worker path is not supported')
@@ -632,7 +715,11 @@ export function runAsWorker<T extends AnyFn<Promise<R> | R>, R = ReturnType<T>>(
 
   const { workerPort, sharedBuffer, pnpLoaderPath } = workerData as WorkerData
 
-  if (pnpLoaderPath && NODE_VERSION >= LOADER_SUPPORTED_NODE_VERSION) {
+  if (
+    pnpLoaderPath &&
+    // >=
+    compareVersion(NODE_VERSION, LOADER_SUPPORTED_NODE_VERSION) >= 0
+  ) {
     module.register(pnpLoaderPath)
   }
 
@@ -657,7 +744,7 @@ export function runAsWorker<T extends AnyFn<Promise<R> | R>, R = ReturnType<T>>(
           msg = { id, error, properties: extractProperties(error) }
         }
         workerPort.off('message', handleAbortMessage)
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- false positive for `handleAbortMessage`
         if (isAborted) {
           return
         }


Test patch:
diff --git a/test/helpers.ts b/test/helpers.ts
index b1a224b..a84ca35 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -4,16 +4,20 @@ import WorkerThreads from 'node:worker_threads'
 
 import { jest } from '@jest/globals'
 
-import { MTS_SUPPORTED_NODE_VERSION } from 'synckit'
+import {
+  compareVersion,
+  MTS_SUPPORTED_NODE_VERSION,
+  NODE_VERSION,
+} from 'synckit'
 
 export const _dirname = path.dirname(fileURLToPath(import.meta.url))
 
-export const nodeVersion = Number.parseFloat(process.versions.node)
-
 export const tsUseEsmSupported =
   // https://github.com/privatenumber/tsx/issues/354
-  // eslint-disable-next-line @typescript-eslint/no-magic-numbers
-  nodeVersion >= MTS_SUPPORTED_NODE_VERSION && nodeVersion <= 18.18
+  // >=
+  compareVersion(NODE_VERSION, MTS_SUPPORTED_NODE_VERSION) >= 0 &&
+  // <=
+  compareVersion(NODE_VERSION, '18.18') <= 0
 
 export const testIf = (condition: boolean) => (condition ? it : it.skip)
 
diff --git a/test/ts-runner.spec.ts b/test/ts-runner.spec.ts
index 59ccd4d..67c5a1d 100644
--- a/test/ts-runner.spec.ts
+++ b/test/ts-runner.spec.ts
@@ -4,13 +4,16 @@ import path from 'node:path'
 
 import { jest } from '@jest/globals'
 
-import { _dirname, nodeVersion, tsUseEsmSupported } from './helpers.js'
+import { _dirname, tsUseEsmSupported } from './helpers.js'
 import type { AsyncWorkerFn } from './types.js'
 
 import {
   MTS_SUPPORTED_NODE_VERSION,
-  TYPESCRIPT_DEFAULT_NODE_VERSION,
+  NODE_VERSION,
+  STRIP_TYPES_NODE_VERSION,
   TsRunner,
+  NO_STRIP_TYPES_FLAG,
+  compareVersion,
 } from 'synckit'
 
 beforeEach(() => {
@@ -112,7 +115,8 @@ test(TsRunner.TSX, async () => {
   expect(syncFn(2)).toBe(2)
   expect(syncFn(5)).toBe(5)
 
-  if (nodeVersion < MTS_SUPPORTED_NODE_VERSION) {
+  // <
+  if (compareVersion(NODE_VERSION, MTS_SUPPORTED_NODE_VERSION) < 0) {
     // eslint-disable-next-line jest/no-conditional-expect
     expect(() =>
       createSyncFn<AsyncWorkerFn>(workerMtsPath, {
@@ -137,7 +141,8 @@ test(TsRunner.TSX, async () => {
 test(TsRunner.Node, async () => {
   const { createSyncFn } = await import('synckit')
 
-  if (!process.features.typescript) {
+  // <
+  if (compareVersion(NODE_VERSION, STRIP_TYPES_NODE_VERSION) < 0) {
     // eslint-disable-next-line jest/no-conditional-expect
     expect(() =>
       createSyncFn<AsyncWorkerFn>(workerMtsPath, {
@@ -147,12 +152,15 @@ test(TsRunner.Node, async () => {
     return
   }
 
-  let syncFn = createSyncFn<AsyncWorkerFn>(workerJsPath, {
-    tsRunner:
-      nodeVersion >= TYPESCRIPT_DEFAULT_NODE_VERSION
-        ? undefined
-        : TsRunner.Node,
-  })
+  expect(() =>
+    createSyncFn<AsyncWorkerFn>(workerMtsPath, {
+      tsRunner: TsRunner.Node,
+      execArgv: [NO_STRIP_TYPES_FLAG],
+    }),
+  ).toThrow('type stripping is disabled explicitly')
+
+  let syncFn = createSyncFn<AsyncWorkerFn>(workerJsPath)
+
   expect(syncFn(1)).toBe(1)
   expect(syncFn(2)).toBe(2)
   expect(syncFn(5)).toBe(5)
@@ -161,12 +169,7 @@ test(TsRunner.Node, async () => {
     return
   }
 
-  syncFn = createSyncFn<AsyncWorkerFn>(workerMtsPath, {
-    tsRunner:
-      nodeVersion >= TYPESCRIPT_DEFAULT_NODE_VERSION
-        ? undefined
-        : TsRunner.Node,
-  })
+  syncFn = createSyncFn<AsyncWorkerFn>(workerMtsPath)
   expect(syncFn(1)).toBe(1)
   expect(syncFn(2)).toBe(2)
   expect(syncFn(5)).toBe(5)