diff --git a/docs/config/worker-options.md b/docs/config/worker-options.md index 91edfe173a2e60..222562858b5ab4 100644 --- a/docs/config/worker-options.md +++ b/docs/config/worker-options.md @@ -4,10 +4,10 @@ Unless noted, the options in this section are applied to all dev, build, and pre ## worker.format -- **Type:** `'es' | 'iife'` +- **Type:** `'es' | 'iife' | 'cjs'` - **Default:** `'iife'` -Output format for worker bundle. +Output format for worker bundle. When using [`?nodeWorker`](/guide/features#node-worker-imports), prefer `'es'` or `'cjs'`. Other formats will be coerced to `'cjs'` for Node worker builds. ## worker.plugins diff --git a/docs/guide/features.md b/docs/guide/features.md index c7caead35756f7..5a91f779d5357b 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -765,6 +765,25 @@ import 'vite/client' import MyWorker from './worker?worker&url' ``` +#### Node Worker Imports + +When targeting Node.js worker threads, append the `?nodeWorker` query. The default export is a factory that returns a [`Worker`](https://nodejs.org/api/worker_threads.html#class-worker) from `node:worker_threads`, so it can be used directly in server-side code during development and after build: + +```ts twoslash +import 'vite/client' + +import createNodeWorker from './worker?nodeWorker' + +const worker = createNodeWorker() +worker.postMessage('ping') +worker.on('message', (value: string) => { + console.log(value) + worker.terminate() +}) +``` + +The same modifiers as web workers are supported. For example, `?nodeWorker&inline` inlines the worker source and runs it with `eval: true`. Node workers currently support `worker.format` values of `'es'` and `'cjs'`; when another format is configured Vite will fall back to `'cjs'`. + See [Worker Options](/config/worker-options.md) for details on configuring the bundling of all workers. ## Content Security Policy (CSP) diff --git a/packages/vite/client.d.ts b/packages/vite/client.d.ts index 3a9dd88f771f0d..e3ce37a7d746c6 100644 --- a/packages/vite/client.d.ts +++ b/packages/vite/client.d.ts @@ -221,6 +221,20 @@ declare module '*?worker&url' { export default src } +declare module '*?nodeWorker' { + const workerFactory: ( + options?: import('node:worker_threads').WorkerOptions, + ) => import('node:worker_threads').Worker + export default workerFactory +} + +declare module '*?nodeWorker&inline' { + const workerFactory: ( + options?: import('node:worker_threads').WorkerOptions, + ) => import('node:worker_threads').Worker + export default workerFactory +} + declare module '*?sharedworker' { const sharedWorkerConstructor: { new (options?: { name?: string }): SharedWorker diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index d5dee474ce6a34..6d91e10fb47644 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -436,7 +436,7 @@ export interface UserConfig extends DefaultEnvironmentOptions { * Output format for worker bundle * @default 'iife' */ - format?: 'es' | 'iife' + format?: 'es' | 'iife' | 'cjs' /** * Vite plugins that apply to worker bundle. The plugins returned by this function * should be new instances every time it is called, because they are used for each @@ -536,7 +536,7 @@ export interface LegacyOptions { } export interface ResolvedWorkerOptions { - format: 'es' | 'iife' + format: 'es' | 'iife' | 'cjs' plugins: (bundleChain: string[]) => Promise rollupOptions: RollupOptions } @@ -1437,6 +1437,7 @@ export async function resolveConfig( const workerResolved: ResolvedConfig = { ...workerConfig, ...resolved, + command: 'build', isWorker: true, mainConfig: resolved, bundleChain, diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 63cb172d052d06..420426ac8201bb 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -257,6 +257,9 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const environment = this.environment as DevEnvironment const ssr = environment.config.consumer === 'server' const moduleGraph = environment.moduleGraph + if (!moduleGraph) { + return source + } if (canSkipImportAnalysis(importer)) { debug?.(colors.dim(`[skipped] ${prettifyUrl(importer, root)}`)) diff --git a/packages/vite/src/node/plugins/worker.ts b/packages/vite/src/node/plugins/worker.ts index 9682e7a1526f85..23bed4b74f0cea 100644 --- a/packages/vite/src/node/plugins/worker.ts +++ b/packages/vite/src/node/plugins/worker.ts @@ -1,6 +1,13 @@ import path from 'node:path' +import { builtinModules } from 'node:module' import MagicString from 'magic-string' -import type { RollupError, RollupOutput } from 'rollup' +import type { + InternalModuleFormat, + PluginContext, + RollupError, + RollupOutput, + SourceMapInput, +} from 'rollup' import colors from 'picocolors' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' @@ -32,6 +39,11 @@ type WorkerBundle = { watchedFiles: string[] } +interface BundleWorkerEntryOptions { + cacheKey?: string + format?: InternalModuleFormat +} + type WorkerBundleAsset = { fileName: string /** @deprecated */ @@ -55,7 +67,7 @@ class WorkerOutputCache { private invalidatedBundles = new Set() saveWorkerBundle( - file: string, + cacheKey: string, watchedFiles: string[], outputEntryFilename: string, outputEntryCode: string, @@ -73,7 +85,7 @@ class WorkerOutputCache { referencedAssets: new Set(outputAssets.map((asset) => asset.fileName)), watchedFiles, } - this.bundles.set(file, bundle) + this.bundles.set(cacheKey, bundle) return bundle } @@ -100,18 +112,18 @@ class WorkerOutputCache { } } - removeBundleIfInvalidated(file: string) { - if (this.invalidatedBundles.has(file)) { - this.invalidatedBundles.delete(file) - this.removeBundle(file) + removeBundleIfInvalidated(cacheKey: string) { + if (this.invalidatedBundles.has(cacheKey)) { + this.invalidatedBundles.delete(cacheKey) + this.removeBundle(cacheKey) } } - private removeBundle(file: string) { - const bundle = this.bundles.get(file) + private removeBundle(cacheKey: string) { + const bundle = this.bundles.get(cacheKey) if (!bundle) return - this.bundles.delete(file) + this.bundles.delete(cacheKey) this.fileNameHash.delete(getHash(bundle.entryFilename)) this.assets.delete(bundle.entryFilename) @@ -125,8 +137,8 @@ class WorkerOutputCache { } } - getWorkerBundle(file: string) { - return this.bundles.get(file) + getWorkerBundle(cacheKey: string) { + return this.bundles.get(cacheKey) } getAssets() { @@ -149,23 +161,29 @@ class WorkerOutputCache { export type WorkerType = 'classic' | 'module' | 'ignore' export const workerOrSharedWorkerRE: RegExp = - /(?:\?|&)(worker|sharedworker)(?:&|$)/ + /(?:\?|&)(worker|sharedworker|nodeworker)(?:&|$)/i const workerFileRE = /(?:\?|&)worker_file&type=(\w+)(?:&|$)/ const inlineRE = /[?&]inline\b/ export const WORKER_FILE_ID = 'worker_file' const workerOutputCaches = new WeakMap() +const nodeBuiltinIds = new Set([ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), +]) async function bundleWorkerEntry( config: ResolvedConfig, id: string, + options: BundleWorkerEntryOptions = {}, ): Promise { const input = cleanUrl(id) + const cacheKey = options.cacheKey ?? input const workerOutput = workerOutputCaches.get(config.mainConfig || config)! - workerOutput.removeBundleIfInvalidated(input) + workerOutput.removeBundleIfInvalidated(cacheKey) - const bundleInfo = workerOutput.getWorkerBundle(input) + const bundleInfo = workerOutput.getWorkerBundle(cacheKey) if (bundleInfo) { return bundleInfo } @@ -181,16 +199,40 @@ async function bundleWorkerEntry( // bundle the file as entry to support imports const { rollup } = await import('rollup') const { plugins, rollupOptions, format } = config.worker + const targetFormat = options.format ?? format const workerConfig = await plugins(newBundleChain) const workerEnvironment = new BuildEnvironment('client', workerConfig) // TODO: should this be 'worker'? await workerEnvironment.init() + const workerPlugins = workerEnvironment.plugins.filter((plugin) => { + return !plugin.name?.includes('import-analysis') + }) + + const userExternal = rollupOptions?.external + const isNodeWorker = + options.format === 'cjs' || + (options.format === 'es' && options.cacheKey?.includes('nodeworker')) + + const resolvedExternal = isNodeWorker + ? typeof userExternal === 'function' + ? (id: string, ...rest: any[]) => + nodeBuiltinIds.has(id) || (userExternal as any)(id, ...rest) + : [ + ...(Array.isArray(userExternal) + ? userExternal + : userExternal + ? [userExternal] + : []), + ...nodeBuiltinIds, + ] + : userExternal const bundle = await rollup({ ...rollupOptions, input, - plugins: workerEnvironment.plugins.map((p) => + plugins: workerPlugins.map((p) => injectEnvironmentToHooks(workerEnvironment, p), ), + external: resolvedExternal, onLog(level, log) { onRollupLog(level, log, workerEnvironment) }, @@ -219,7 +261,7 @@ async function bundleWorkerEntry( '[name]-[hash].[ext]', ), ...workerConfig, - format, + format: targetFormat, sourcemap: config.build.sourcemap, }) watchedFiles = bundle.watchFiles.map((f) => normalizePath(f)) @@ -266,7 +308,7 @@ async function bundleWorkerEntry( const newBundleInfo = workerOutputCaches .get(config.mainConfig || config)! .saveWorkerBundle( - input, + cacheKey, watchedFiles, outputChunk.fileName, outputChunk.code, @@ -281,9 +323,10 @@ export const workerAssetUrlRE: RegExp = /__VITE_WORKER_ASSET__([a-z\d]{8})__/g export async function workerFileToUrl( config: ResolvedConfig, id: string, + options?: BundleWorkerEntryOptions, ): Promise { const workerOutput = workerOutputCaches.get(config.mainConfig || config)! - const bundle = await bundleWorkerEntry(config, id) + const bundle = await bundleWorkerEntry(config, id, options) workerOutput.saveAsset( { fileName: bundle.entryFilename, @@ -342,9 +385,16 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { const workerMatch = workerOrSharedWorkerRE.exec(id) if (!workerMatch) return + const queryType = workerMatch[1].toLowerCase() + const inline = inlineRE.test(id) + + if (queryType === 'nodeworker') { + return loadNodeWorkerModule.call(this, config, id, inline) + } + const { format } = config.worker const workerConstructor = - workerMatch[1] === 'sharedworker' ? 'SharedWorker' : 'Worker' + queryType === 'sharedworker' ? 'SharedWorker' : 'Worker' const workerType = isBuild ? format === 'es' ? 'module' @@ -359,7 +409,7 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { if (isBuild) { if (isWorker && config.bundleChain.at(-1) === cleanUrl(id)) { urlCode = 'self.location.href' - } else if (inlineRE.test(id)) { + } else if (inline) { const result = await bundleWorkerEntry(config, id) for (const file of result.watchedFiles) { this.addWatchFile(file) @@ -577,6 +627,125 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { } } +let didWarnUnsupportedNodeWorkerFormat = false + +async function loadNodeWorkerModule( + this: PluginContext, + config: ResolvedConfig, + id: string, + inline: boolean, +) { + const nodeFormat = resolveNodeWorkerFormat(config) + const workerType: Exclude = + nodeFormat === 'es' ? 'module' : 'classic' + const isBuild = config.command === 'build' + const cacheKey = `${cleanUrl(id)}?nodeworker&variant=${ + inline ? 'inline' : 'chunk' + }&format=${nodeFormat}` + const bundleOptions: BundleWorkerEntryOptions = { + cacheKey, + format: nodeFormat, + } + + const bundle = + isBuild && !inline + ? await workerFileToUrl(config, id, bundleOptions) + : await bundleWorkerEntry(config, id, bundleOptions) + + for (const file of bundle.watchedFiles || []) { + this.addWatchFile(file) + } + + if (isBuild && !inline) { + const urlExpression = JSON.stringify(bundle.entryUrlPlaceholder) + return { + code: createNodeWorkerChunkModule(urlExpression, workerType), + map: { mappings: '' } as SourceMapInput, + } + } + + const sourceLiteral = JSON.stringify(bundle.entryCode) + return { + code: createNodeWorkerInlineModule(sourceLiteral, workerType), + map: { mappings: '' } as SourceMapInput, + } +} + +function resolveNodeWorkerFormat(config: ResolvedConfig): InternalModuleFormat { + const format = config.worker?.format ?? 'iife' + if (format === 'es' || format === 'cjs') { + return format + } + if (!didWarnUnsupportedNodeWorkerFormat) { + config.logger.warn( + colors.yellow( + `?nodeWorker currently supports only worker.format "es" or "cjs". Falling back to "cjs".`, + ), + ) + didWarnUnsupportedNodeWorkerFormat = true + } + return 'cjs' +} + +function createNodeWorkerChunkModule( + urlExpression: string, + workerType: Exclude, +) { + const lines = [ + "import { Worker } from 'node:worker_threads'", + "import path from 'node:path'", + "import { fileURLToPath, pathToFileURL } from 'node:url'", + '', + `const workerReference = ${urlExpression}`, + 'export default function WorkerWrapper(options) {', + ' const workerOptions = options ? { ...options } : {}', + ] + if (workerType === 'module') { + lines.push( + ' if (workerOptions.type == null) workerOptions.type = "module"', + ) + } + lines.push( + ' const workerPath =', + " typeof workerReference === 'string' && workerReference.startsWith('/')", + ' ? workerReference.slice(1)', + ' : workerReference', + ' const workerUrl =', + " typeof workerReference === 'string'", + ' ? pathToFileURL(', + ' path.resolve(', + ' path.dirname(fileURLToPath(import.meta.url)),', + ' workerPath,', + ' ),', + ' )', + ' : workerReference', + ' return new Worker(workerUrl, workerOptions)', + '}', + ) + return lines.join('\n') +} + +function createNodeWorkerInlineModule( + sourceLiteral: string, + workerType: Exclude, +) { + const lines = [ + "import { Worker } from 'node:worker_threads'", + '', + `const workerSource = ${sourceLiteral}`, + 'export default function WorkerWrapper(options) {', + ' const workerOptions = options ? { ...options } : {}', + ] + if (workerType === 'module') { + lines.push( + ' if (workerOptions.type == null) workerOptions.type = "module"', + ) + } + lines.push(' if (workerOptions.eval == null) workerOptions.eval = true') + lines.push(' return new Worker(workerSource, workerOptions)', '}') + return lines.join('\n') +} + function isSameContent(a: string | Uint8Array, b: string | Uint8Array) { if (typeof a === 'string') { if (typeof b === 'string') { diff --git a/playground/node-worker/__tests__/node-worker.spec.ts b/playground/node-worker/__tests__/node-worker.spec.ts new file mode 100644 index 00000000000000..4ceda4a0f8f05b --- /dev/null +++ b/playground/node-worker/__tests__/node-worker.spec.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, test } from 'vitest' +import { isBuild, testDir, viteServer } from '~utils' + +async function loadModule() { + if (isBuild) { + const entryFile = path.resolve(testDir, 'dist/main.mjs') + return import(pathToFileURL(entryFile).href) + } + + if (!viteServer) { + throw new Error('Expected dev server to be available in serve mode') + } + + return viteServer.ssrLoadModule('/main.ts') +} + +test('chunk worker responds', async () => { + const mod = await loadModule() + expect(await mod.run('ping')).toBe('chunk:ping') +}) + +test('inline worker responds', async () => { + const mod = await loadModule() + expect(await mod.runInline('inline')).toBe('inline:inline') +}) + +describe.runIf(isBuild)('build output', () => { + test('emits node worker chunk with node imports', async () => { + const distPath = path.resolve(testDir, 'dist') + const mainContent = fs.readFileSync( + path.join(distPath, 'main.mjs'), + 'utf-8', + ) + const assetsDir = path.join(distPath, 'assets') + const assetFiles = fs.readdirSync(assetsDir) + const workerFile = assetFiles.find((file) => file.includes('worker')) + expect(workerFile).toBeDefined() + const workerContent = fs.readFileSync( + path.join(assetsDir, workerFile!), + 'utf-8', + ) + + expect(mainContent).toMatch(`from "node:worker_threads"`) + expect(mainContent).toMatch(`from "node:path"`) + expect(mainContent).toMatch(`from "node:url"`) + expect(mainContent).toMatch(`pathToFileURL(`) + expect(mainContent).toMatch(`type == null) workerOptions.type = "module"`) + expect(workerContent).toMatch(`from"node:worker_threads"`) + }) +}) diff --git a/playground/node-worker/main.ts b/playground/node-worker/main.ts new file mode 100644 index 00000000000000..8816724c43f483 --- /dev/null +++ b/playground/node-worker/main.ts @@ -0,0 +1,46 @@ +import type { Worker } from 'node:worker_threads' +import createChunkWorker from './worker?nodeWorker' +import createInlineWorker from './worker-inline?nodeWorker&inline' + +function executeWorker(factory: () => Worker, message: string) { + return new Promise((resolve, reject) => { + const worker = factory() + + const cleanup = async () => { + try { + await worker.terminate() + } catch { + // ignore termination errors during cleanup + } + } + + worker.once('message', async (data) => { + await cleanup() + resolve(String(data)) + }) + + worker.once('error', async (error) => { + await cleanup() + reject(error) + }) + + worker.postMessage(message) + }) +} + +export function run(message: string) { + return executeWorker(createChunkWorker, message) +} + +export function runInline(message: string) { + return executeWorker(createInlineWorker, message) +} + +export async function runBoth(message: string) { + const [chunkResult, inlineResult] = await Promise.all([ + run(message), + runInline(message), + ]) + + return { chunkResult, inlineResult } +} diff --git a/playground/node-worker/package.json b/playground/node-worker/package.json new file mode 100644 index 00000000000000..6a04799441630b --- /dev/null +++ b/playground/node-worker/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vitejs/test-node-worker", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + } +} diff --git a/playground/node-worker/vite.config.ts b/playground/node-worker/vite.config.ts new file mode 100644 index 00000000000000..f59d81fc303681 --- /dev/null +++ b/playground/node-worker/vite.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path' +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + target: 'node18', + ssr: true, + ssrEmitAssets: true, + rollupOptions: { + input: { + main: path.resolve(__dirname, 'main.ts'), + }, + output: { + format: 'es', + entryFileNames: '[name].mjs', + }, + }, + }, + worker: { + format: 'es', + }, +}) diff --git a/playground/node-worker/worker-inline.ts b/playground/node-worker/worker-inline.ts new file mode 100644 index 00000000000000..823845b903ac52 --- /dev/null +++ b/playground/node-worker/worker-inline.ts @@ -0,0 +1,9 @@ +import { parentPort } from 'node:worker_threads' + +if (!parentPort) { + throw new Error('Expected parentPort to be available in node worker') +} + +parentPort.on('message', (message) => { + parentPort!.postMessage(`inline:${message}`) +}) diff --git a/playground/node-worker/worker.ts b/playground/node-worker/worker.ts new file mode 100644 index 00000000000000..8f34255d371131 --- /dev/null +++ b/playground/node-worker/worker.ts @@ -0,0 +1,9 @@ +import { parentPort } from 'node:worker_threads' + +if (!parentPort) { + throw new Error('Expected parentPort to be available in node worker') +} + +parentPort.on('message', (message) => { + parentPort!.postMessage(`chunk:${message}`) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cbdde0b59f72e..c47486cda99137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -977,6 +977,8 @@ importers: playground/nested-deps/test-package-f: {} + playground/node-worker: {} + playground/object-hooks: {} playground/optimize-deps: