ts-patch
ts-patch copied to clipboard
Maximum call stack size exceeded. ts-patch + ts-node
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();
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)