nonara / ts-patch

Augment the TypeScript compiler to support extended functionality
MIT License
773 stars 26 forks source link

Maximum call stack size exceeded. ts-patch + ts-node #145

Open i124q2n8 opened 10 months ago

i124q2n8 commented 10 months ago

We ran into an issue with ts-node + ts-patch where large projects or projects that use hmr fail to compile.

RangeError: Maximum call stack size exceeded
    at console.log (node:internal/console/constructor:378:6)
    at /ts-node-ts-patch-bug/transformer.ts:30:21
    at tspWrappedFactory (evalmachine.<anonymous>:223:31)
    at transformSourceFileOrBundle (evalmachine.<anonymous>:89683:51)
    at transformation (evalmachine.<anonymous>:112809:16)
    at transformRoot (evalmachine.<anonymous>:112832:73)
    at transformNodes (evalmachine.<anonymous>:112817:72)
    at emitJsFileOrBundle (evalmachine.<anonymous>:113404:26)
    at emitSourceFileOrBundle (evalmachine.<anonymous>:113339:7)
    at forEachEmittedFile (evalmachine.<anonymous>:113093:26)

After some digging it seems that ts-patch causes ts-node to call registerExtension every time a transformer is used. registerExtensions calls all old handlers which leads to a unnecessary long chain of handlers. See https://github.com/TypeStrong/ts-node/blob/ddb05ef23be92a90c3ecac5a0220435c65ebbd2a/src/index.ts#L1341

I am not sure if the issue is caused by ts-patch or ts-node, but the following line in ts-patch calls into ts-node, which in turn re-registers the extensions: https://github.com/nonara/ts-patch/blob/b4b50de8acdee25f69c3902fdd6eab22194ec891/projects/patch/src/plugin/register-plugin.ts#L111

If this is an issue in ts-node I am happy to report it there.

Minimal reproducible example:

src/main.ts

import { resolve } from "path";

async function main() {
  for (let i = 0; i < 1_000_000; i++) {
    const { test } = await import("./test");
    test();
    // simulate: hot module replacement
    delete require.cache[resolve("./src/test.ts")];
  }
}
main();

(Note: Another main.ts without delete require.cache[...] can be found below)

src/test.ts

export function test() {
  console.log("test");
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "outDir": "dist",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "sourceMap": true,
    "plugins": [
      {
        "transform": "./transformer.ts",
        "after": false
      },
    ]
  },
  "ts-node": {
    // "transpileOnly": true,
    "files": true,
    "compiler": "ts-patch/compiler"
  }
}

transformer.ts

import * as ts from "typescript";

export default function (
  program: ts.Program,
  pluginOptions: Record<string, never>
) {
  return (context: ts.TransformationContext) => {
    return (sourceFile: ts.SourceFile) => {
      console.log("transformer");
      function visitor(node: ts.Node): ts.Node {
        //some fancy transformation
        return node;
      }
      return ts.visitNode(sourceFile, visitor);
    };
  };
}

package.json

{
  "name": "ts-node-ts-patch-bug",
  "version": "1.0.0",
  "main": "src/main.ts",
  "scripts": {
    "dev": "nodemon"
  },
  "devDependencies": {
    "@types/node": "^20.11.1",
    "nodemon": "^3.0.2",
    "ts-node": "^10.9.2",
    "ts-patch": "^3.1.2",
    "typescript": "^5.3.3"
  }
}

Another example

The same error occurs when importing ~2500 distinct files. Simulated by copying ./src/test.ts to ./src/o/{i}.ts and importing it.

src/main.ts

import { readFile, writeFile } from "fs/promises";

async function main() {
  const content = await readFile("./src/test.ts", "utf-8");
  for (let i = 0; i < 1_000_000; i++) {
    console.log(i);
    await writeFile(`./src/o/${i}.ts`, content);
    const { test } = await import(`./o/${i}`);
    test();
  }
}
main();
i124q2n8 commented 10 months ago

In case someone else got this issue. Here is a hacky workaround: node --stack-size=100000 -r ts-node/register src/main.ts. Note that the stack will continue to grow and importing gets slower for each import.


Update 2024-04-23

Increasing the stack-size is only a temporary fix as this will crash node after a (long) while.

We now register a helper AFTER ts-node: node -r ts-node/register -r ./ts-patch-ts-node-workaround.js src/main.ts

ts-patch-ts-node-workaround.js:

const originalExtensions = Object.fromEntries(
  Object.entries(require.extensions).map(([k, v]) => {
    return [
      k,
      (module, filename) => {
        restore();
        return v(module, filename);
      },
    ];
  }),
);

function restore() {
  for (const [k, v] of Object.entries(originalExtensions)) {
    if (require.extensions[k] !== v) {
      require.extensions[k] = v;
    }
  }
}

restore();

This hook restores the original ts-node handlers after each import and thus remove the unnecessary recursive calls. (Note that you are no longer able to register new extensions afterwards)