Repository navigation
Maximum call stack size exceeded. ts-patch + ts-node #145
Description
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:
| tsNodeInstance = tsNode.register({ |
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();
Activity
i124q2n8 commentedon Jan 18, 2024
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.tsts-patch-ts-node-workaround.js:
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)