DEV Community

Composite
Composite

Posted on

node.js Worker thread with Typescript

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

Enter fullscreen mode Exit fullscreen mode

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:

  1. vite-node
  2. A worker_wrapper.mjs file
  3. parent.ts and child.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();

Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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)