Have you encountered this frustrating error since updating to Node.js 20?
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for src/App.ts
This isn't just a minor glitch; it’s a long-standing issue in the ecosystem. You can see the ongoing discussion here:
The problem is especially painful when trying to run .ts files using Worker. No matter what you try, that error often greets you. Even tools like ts-worker from Bree don't fully support ESM yet, leaving many of us in a tough spot.
But there is a way out! If you are using Vite in your environment, the solution is closer than you think. The Vite team provides vite-node, which allows you to run scripts within your Vite environment.
The Solution: Using vite-node
One tricky part remains: Node.js Worker still primarily expects .js or .mjs extensions. To bridge this gap, we'll use a small wrapper strategy.
Here’s our setup:
vite-node- A
worker_wrapper.mjsfile -
parent.tsandchild.ts
It sounds a bit complex, but it's actually quite simple once you see the code.
1. worker_wrapper.mjs
This file uses the vite-node programmatic API to load and execute our TypeScript worker.
import { workerData } from 'node:worker_threads';
import { createServer } from 'vite';
import { ViteNodeRunner } from 'vite-node/client';
import { ViteNodeServer } from 'vite-node/server';
import { installSourcemapsSupport } from 'vite-node/source-map';
// create vite server
const server = await createServer({
optimizeDeps: {
// It's recommended to disable deps optimization
noDiscovery: true,
include: undefined,
},
});
// create vite-node server
const node = new ViteNodeServer(server);
// fixes stacktraces in Errors
installSourcemapsSupport({
getSourceMap: (source) => node.getSourceMap(source),
});
// create vite-node runner
const runner = new ViteNodeRunner({
root: server.config.root,
base: server.config.base,
// when having the server and runner in a different context,
// you will need to handle the communication between them
// and pass to this function
fetchModule(id) {
return node.fetchModule(id);
},
resolveId(id, importer) {
return node.resolveId(id, importer);
},
});
// execute the file
await runner.executeFile(workerData.jobpath);
// close the vite server
await server.close();
2. parent.ts
When initializing the Worker instance, we point it to worker_wrapper.mjs instead of the .ts file directly. We pass the actual target path via workerData.
import { Worker } from 'node:worker_threads';
import path from 'node:path';
// in Vite, you should declare __dirname yourself.
const worker = new Worker('./worker_wrapper.mjs', {
workerData: {
scriptPath: path.resolve(__dirname, './child.ts'),
},
});
worker.on('message', (msg) => console.log('From Worker:', msg));
3. child.ts
This is where your actual worker logic lives. You can write pure TypeScript here!
import { parentPort } from 'node:worker_threads';
const heavyTask = (): string => {
return "Task Completed in TypeScript!";
};
parentPort?.postMessage(heavyTask());
Why this works
With this approach, you can:
- Test efficient thread separation (for schedulers like Bree) during development.
- Avoid messy hacks with Node's experimental loaders.
- Ensure a seamless transition from development to build.
But, you must follow real chunk path for after-built worker!
Happy node.js-ing!
Top comments (0)