diff --git a/docs/guide/features.md b/docs/guide/features.md index 972f41dee15967..bfd672b91ed448 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -336,6 +336,22 @@ By default, the worker script will be emitted as a separate chunk in the product import MyWorker from './worker?worker&inline' ``` +## Node Worker Threads + +Just like Web Workers Node.js worker threads are supported out of the box. You will need to set build target to nodeN and add native modules as external dependencies in rollup options in config. A Node.js worker script can imported by appending `?worker` to the import request. The default export will be the Worker class from the native 'worker_threads' module. Typescript worker file is also supported. + +```js +import MyWorker from './worker?worker' + +const worker = new MyWorker() +``` + +By default, the worker script will be emitted as a separate chunk in the production build. If you wish to inline the worker, add the `inline` query. Inline code will be added with `{eval: true}` + +```js +import MyWorker from './worker?worker&inline' +``` + ## Build Optimizations > Features listed below are automatically applied as part of the build process and there is no need for explicit configuration unless you want to disable them. diff --git a/packages/playground/worker-thread-node/__tests__/serve.js b/packages/playground/worker-thread-node/__tests__/serve.js new file mode 100644 index 00000000000000..bc8502d623c879 --- /dev/null +++ b/packages/playground/worker-thread-node/__tests__/serve.js @@ -0,0 +1,34 @@ +const { join } = require('path') +/** + * @param {string} root + * @param {boolean} isProd + */ +exports.serve = async function serve(root, isProd) { + // make a build in either cases + // make minified build if prod + if (isProd) { + const { build } = require('vite') + await build({ + configFile: join(root, 'vite.config.ts'), + root, + logLevel: 'silent' + }) + } else { + const { build } = require('vite') + + const config = require(join(root, 'vite.config.ts')).serveConfig + await build({ + ...config, + root, + logLevel: 'silent' + }) + } + + return new Promise((resolve, _) => { + resolve({ + close: async () => { + // no need to close anything + } + }) + }) +} diff --git a/packages/playground/worker-thread-node/__tests__/worker-thread-node.spec.ts b/packages/playground/worker-thread-node/__tests__/worker-thread-node.spec.ts new file mode 100644 index 00000000000000..a8d64dbc2d6c79 --- /dev/null +++ b/packages/playground/worker-thread-node/__tests__/worker-thread-node.spec.ts @@ -0,0 +1,48 @@ +import { isBuild, testDir } from '../../testUtils' +import fs from 'fs-extra' +import path from 'path' + +test('response from worker', async () => { + const distDir = path.resolve(testDir, 'dist') + const { run } = require(path.join(distDir, 'main.cjs')) + expect(await run('ping')).toBe('pong') +}) + +test('response from inline worker', async () => { + const distDir = path.resolve(testDir, 'dist') + const { inlineWorker } = require(path.join(distDir, 'main.cjs')) + expect(await inlineWorker('ping')).toBe('this is inline node worker') +}) + +if (isBuild) { + test('worker code generation', async () => { + const assetsDir = path.resolve(testDir, 'dist/assets') + const distDir = path.resolve(testDir, 'dist') + const files = fs.readdirSync(assetsDir) + const mainContent = fs.readFileSync( + path.resolve(distDir, 'main.cjs.js'), + 'utf-8' + ) + + const workerFile = files.find((f) => f.includes('worker')) + const workerContent = fs.readFileSync( + path.resolve(assetsDir, workerFile), + 'utf-8' + ) + + expect(files.length).toBe(1) + + // main file worker chunk content + expect(mainContent).toMatch(`require("worker_threads")`) + expect(mainContent).toMatch(`Worker`) + + // main content should contain __dirname to resolve module as relation path from main module + expect(mainContent).toMatch(`__dirname`) + + // should resolve worker_treads from external dependency + expect(workerContent).toMatch(`require("worker_threads")`) + + // inline nodejs worker + expect(mainContent).toMatch(`{eval:!0}`) + }) +} diff --git a/packages/playground/worker-thread-node/main.ts b/packages/playground/worker-thread-node/main.ts new file mode 100644 index 00000000000000..3683a1f8dd7819 --- /dev/null +++ b/packages/playground/worker-thread-node/main.ts @@ -0,0 +1,35 @@ +import MyWorker from './worker?worker' +import InlineWorker from './worker-inline?worker&inline' +import { Worker } from 'worker_threads' + +export const run = async (message: string): Promise => { + return new Promise((resolve, _) => { + const worker = new MyWorker() as unknown as Worker + worker.postMessage(message) + worker.on('message', (msg) => { + worker.terminate() + resolve(msg) + }) + }) +} + +export const inlineWorker = async (message: string): Promise => { + return new Promise((resolve, _) => { + const worker = new InlineWorker() as unknown as Worker + worker.postMessage(message) + worker.on('message', (msg) => { + worker.terminate() + resolve(msg) + }) + }) +} + +if (require.main === module) { + Promise.all([run('ping'), inlineWorker('ping')]).then( + ([chunkResponse, inlineResponse]) => { + console.log('Response from chunk worker - ', chunkResponse) + console.log('Response from inline worker - ', inlineResponse) + process.exit() + } + ) +} diff --git a/packages/playground/worker-thread-node/package.json b/packages/playground/worker-thread-node/package.json new file mode 100644 index 00000000000000..5371fcbc5e3f71 --- /dev/null +++ b/packages/playground/worker-thread-node/package.json @@ -0,0 +1,10 @@ +{ + "name": "worker-thread-node", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "node scripts/build.js", + "build": "node scripts/build.js", + "start": "node dist/main.cjs.js" + } +} diff --git a/packages/playground/worker-thread-node/scripts/build.js b/packages/playground/worker-thread-node/scripts/build.js new file mode 100644 index 00000000000000..8f2af5599d0fa4 --- /dev/null +++ b/packages/playground/worker-thread-node/scripts/build.js @@ -0,0 +1,3 @@ +const vite = require('vite') +const { build } = vite +build({ configFile: 'vite.config.ts' }) diff --git a/packages/playground/worker-thread-node/vite.config.ts b/packages/playground/worker-thread-node/vite.config.ts new file mode 100644 index 00000000000000..ab709ff9df6000 --- /dev/null +++ b/packages/playground/worker-thread-node/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite' +import { builtinModules } from 'module' + +export default defineConfig({ + build: { + target: 'node16', + outDir: 'dist', + lib: { + entry: 'main.ts', + formats: ['cjs'], + fileName: 'main' + }, + rollupOptions: { + external: [...builtinModules] + }, + emptyOutDir: true + } +}) + +export const serveConfig = defineConfig({ + build: { + target: 'node16', + outDir: 'dist', + lib: { + entry: 'main.ts', + formats: ['cjs'], + fileName: 'main' + }, + rollupOptions: { + external: [...builtinModules] + }, + minify: false, + emptyOutDir: true + } +}) diff --git a/packages/playground/worker-thread-node/worker-inline.ts b/packages/playground/worker-thread-node/worker-inline.ts new file mode 100644 index 00000000000000..c4b77ced37cfb6 --- /dev/null +++ b/packages/playground/worker-thread-node/worker-inline.ts @@ -0,0 +1,5 @@ +import { parentPort } from 'worker_threads' + +parentPort.on('message', () => { + parentPort.postMessage('this is inline node worker') +}) diff --git a/packages/playground/worker-thread-node/worker.ts b/packages/playground/worker-thread-node/worker.ts new file mode 100644 index 00000000000000..64095313371ec3 --- /dev/null +++ b/packages/playground/worker-thread-node/worker.ts @@ -0,0 +1,5 @@ +import { parentPort } from 'worker_threads' + +parentPort.on('message', (message) => { + parentPort.postMessage('pong') +}) diff --git a/packages/vite/src/node/plugins/worker.ts b/packages/vite/src/node/plugins/worker.ts index 799233088d6b13..e1edb5c050a70e 100644 --- a/packages/vite/src/node/plugins/worker.ts +++ b/packages/vite/src/node/plugins/worker.ts @@ -3,7 +3,7 @@ import { Plugin } from '../plugin' import { resolvePlugins } from '../plugins' import { parse as parseUrl, URLSearchParams } from 'url' import { fileToUrl, getAssetHash } from './asset' -import { cleanUrl, injectQuery } from '../utils' +import { cleanUrl, injectQuery, isTargetNode } from '../utils' import Rollup from 'rollup' import { ENV_PUBLIC_PATH } from '../constants' import path from 'path' @@ -52,28 +52,52 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { } let url: string + const isNode = isTargetNode(config?.build?.target) + if (isBuild) { // bundle the file as entry to support imports const rollup = require('rollup') as typeof Rollup const bundle = await rollup.rollup({ input: cleanUrl(id), + ...config?.build?.rollupOptions, plugins: await resolvePlugins({ ...config }, [], [], []), onwarn(warning, warn) { onRollupWarning(warning, warn, config) } }) + let code: string try { - const { output } = await bundle.generate({ - format: 'iife', - sourcemap: config.build.sourcemap - }) - code = output[0].code + if (isNode) { + const { output } = await bundle.generate({ + format: 'cjs', + sourcemap: config.build.sourcemap + }) + code = output[0].code + } else { + const { output } = await bundle.generate({ + format: 'iife', + sourcemap: config.build.sourcemap + }) + code = output[0].code + } } finally { await bundle.close() } const content = Buffer.from(code) if (query.inline != null) { + if (isNode) { + let code = content.toString().trim() + code = code.replace(/\r?\n|\r/g, '') + + return ` + import { Worker } from "worker_threads" \n + import { join } from "path" \n + export default function WorkerWrapper() { + return new Worker(\'${code}', { eval: true }) + } + ` + } // inline as blob data url return `const encodedJs = "${content.toString('base64')}"; const blob = typeof window !== "undefined" && window.Blob && new Blob([atob(encodedJs)], { type: "text/javascript;charset=utf-8" }); @@ -107,6 +131,18 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { query.sharedworker != null ? 'SharedWorker' : 'Worker' const workerOptions = { type: 'module' } + if (isNode) { + return ` + import { Worker } from "worker_threads" \n + import { join } from "path" \n + export default function WorkerWrapper() { + return new Worker(join(__dirname, ${JSON.stringify( + url + )}), ${JSON.stringify(workerOptions, null, 2)}) + } + ` + } + return `export default function WorkerWrapper() { return new ${workerConstructor}(${JSON.stringify( url diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 5cbad8004da464..67a11c7e3c66b2 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -558,6 +558,22 @@ export function resolveHostname( return { host, name } } +export function isTargetNode(target: string | false | string[]): boolean { + if (!target) { + return false + } + + if (typeof target === 'string') { + return target.includes('node') + } + + if (Array.isArray(target)) { + return target.some((f) => f.includes('node')) + } + + return false +} + export function arraify(target: T | T[]): T[] { return Array.isArray(target) ? target : [target] } @@ -567,4 +583,5 @@ export function toUpperCaseDriveLetter(pathName: string): string { } export const multilineCommentsRE = /\/\*(.|[\r\n])*?\*\//gm + export const singlelineCommentsRE = /\/\/.*/g