Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow parallel worker builds when worker plugins is a function #14113

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/config/worker-options.md
Expand Up @@ -11,10 +11,14 @@ Output format for worker bundle.

## worker.plugins

- **Type:** [`(Plugin | Plugin[])[]`](./shared-options#plugins)
- **Type:** [`(() => (Plugin | Plugin[])[]) | (Plugin | Plugin[])[]`](./shared-options#plugins)

Vite plugins that apply to worker bundle. Note that [config.plugins](./shared-options#plugins) only applies to workers in dev, it should be configured here instead for build.

Providing a function that returns an array of plugins is preferred, so we can safely run parallel builds for workers. The function should be side effect free and return the same array of plugins every time.

When providing an array of plugins, the worker builds will run sequentially and workers nested inside other workers may not build.

## worker.rollupOptions

- **Type:** [`RollupOptions`](https://rollupjs.org/configuration-options/)
Expand Down
33 changes: 33 additions & 0 deletions packages/vite/src/node/__tests__/config.spec.ts
Expand Up @@ -335,3 +335,36 @@ describe('resolveConfig', () => {
expect(results2.clearScreen).toBe(false)
})
})

describe('worker config', () => {
const userPlugin = (): PluginOption => {
return {
name: 'vite-plugin-worker-user-plugin',
}
}

test('resolves to default plugins function', async () => {
const results = await resolveConfig({}, 'build')
expect(typeof results.worker.plugins).toBe('function')
})

test('resolves to plugins function when user defined function given', async () => {
const config: InlineConfig = {
worker: {
plugins: () => [userPlugin()],
},
}
const results = await resolveConfig(config, 'build')
expect(typeof results.worker.plugins).toBe('function')
})

test('resolves to a plugins array when user defined array given', async () => {
const config: InlineConfig = {
worker: {
plugins: [userPlugin()],
},
}
const results = await resolveConfig(config, 'build')
expect(Array.isArray(results.worker.plugins)).toBe(true)
})
})
64 changes: 44 additions & 20 deletions packages/vite/src/node/config.ts
Expand Up @@ -270,7 +270,7 @@ export interface UserConfig {
/**
* Vite plugins that apply to worker bundle
*/
plugins?: PluginOption[]
plugins?: PluginOption[] | (() => PluginOption[])
/**
* Rollup options to build worker bundle
*/
Expand Down Expand Up @@ -332,7 +332,7 @@ export interface LegacyOptions {

export interface ResolveWorkerOptions extends PluginHookUtils {
format: 'es' | 'iife'
plugins: Plugin[]
plugins: Plugin[] | (() => Promise<Plugin[]>)
rollupOptions: RollupOptions
}

Expand Down Expand Up @@ -454,12 +454,6 @@ export async function resolveConfig(
return p.apply === command
}
}
// Some plugins that aren't intended to work in the bundling of workers (doing post-processing at build time for example).
// And Plugins may also have cached that could be corrupted by being used in these extra rollup calls.
// So we need to separate the worker plugin from the plugin that vite needs to run.
const rawWorkerUserPlugins = (
(await asyncFlatten(config.worker?.plugins || [])) as Plugin[]
).filter(filterPlugin)

// resolve plugins
const rawUserPlugins = (
Expand Down Expand Up @@ -653,18 +647,39 @@ export async function resolveConfig(

const BASE_URL = resolvedBase

// Some plugins that aren't intended to work in the bundling of workers (doing post-processing at build time for example).
// And Plugins may also have cached that could be corrupted by being used in these extra rollup calls.
// So we need to separate the worker plugin from the plugin that vite needs to run.
const rawWorkerUserPlugins = config.worker?.plugins
const supportsUserPluginsFunction =
typeof rawWorkerUserPlugins === 'function' || !rawWorkerUserPlugins
const getSortedWorkerUserPlugins = async (
userPlugins?: PluginOption[] | (() => PluginOption[]),
) => {
const plugins =
typeof userPlugins === 'function' ? userPlugins() : userPlugins
const pluginsFlattened = (
(await asyncFlatten(plugins || [])) as Plugin[]
).filter(filterPlugin)
return sortUserPlugins(pluginsFlattened)
}

// resolve worker
let workerConfig = mergeConfig({}, config)
const [workerPrePlugins, workerNormalPlugins, workerPostPlugins] =
sortUserPlugins(rawWorkerUserPlugins)
await getSortedWorkerUserPlugins(rawWorkerUserPlugins)

// run config hooks
const workerUserPlugins = [
const workerUserPluginsForHooks = [
...workerPrePlugins,
...workerNormalPlugins,
...workerPostPlugins,
]
workerConfig = await runConfigHook(workerConfig, workerUserPlugins, configEnv)
workerConfig = await runConfigHook(
workerConfig,
workerUserPluginsForHooks,
configEnv,
)
const resolvedWorkerOptions: ResolveWorkerOptions = {
format: workerConfig.worker?.format || 'iife',
plugins: [],
Expand Down Expand Up @@ -754,15 +769,17 @@ export async function resolveConfig(
isWorker: true,
mainConfig: resolved,
}
resolvedConfig.worker.plugins = await resolvePlugins(
workerResolved,
workerPrePlugins,
workerNormalPlugins,
workerPostPlugins,
)

const getResolvedWorkerPlugins = async () => {
return resolvePlugins(
workerResolved,
...(await getSortedWorkerUserPlugins(rawWorkerUserPlugins)),
)
}

Object.assign(
resolvedConfig.worker,
createPluginHookUtils(resolvedConfig.worker.plugins),
createPluginHookUtils(await getResolvedWorkerPlugins()),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might need to add some comments. The assumption is that the user worker.plugins function would be side effect free. This allows us to pre-process relevant hook/config updates but still allow fresh plugin instantiation per worker build.

Does that make sense? 馃槄

)

// call configResolved hooks
Expand All @@ -775,6 +792,10 @@ export async function resolveConfig(
.map((hook) => hook(workerResolved)),
])

resolvedConfig.worker.plugins = supportsUserPluginsFunction
? () => getResolvedWorkerPlugins()
: await getResolvedWorkerPlugins()

// validate config

if (middlewareMode === 'ssr') {
Expand Down Expand Up @@ -812,7 +833,10 @@ export async function resolveConfig(
plugins: resolved.plugins.map((p) => p.name),
worker: {
...resolved.worker,
plugins: resolved.worker.plugins.map((p) => p.name),
plugins: (typeof resolved.worker.plugins === 'function'
? await resolved.worker.plugins()
: resolved.worker.plugins
).map((p) => p.name),
},
})

Expand Down Expand Up @@ -856,7 +880,7 @@ assetFileNames isn't equal for every build.rollupOptions.output. A single patter
) {
resolved.logger.warn(
colors.yellow(`
(!) Experimental legacy.buildSsrCjsExternalHeuristics and ssr.format: 'cjs' are going to be removed in Vite 5.
(!) Experimental legacy.buildSsrCjsExternalHeuristics and ssr.format: 'cjs' are going to be removed in Vite 5.
Find more information and give feedback at https://github.com/vitejs/vite/discussions/13816.
`),
)
Expand Down
21 changes: 14 additions & 7 deletions packages/vite/src/node/plugins/worker.ts
Expand Up @@ -42,29 +42,36 @@ function saveEmitWorkerAsset(
}

// Ensure that only one rollup build is called at the same time to avoid
// leaking state in plugins between worker builds.
// TODO: Review if we can parallelize the bundling of workers.
// leaking state in plugins between worker builds when running sequentially.
const workerConfigSemaphore = new WeakMap<
ResolvedConfig,
Promise<OutputChunk>
>()
export async function bundleWorkerEntry(
export async function handleBundleWorkerEntry(
config: ResolvedConfig,
id: string,
query: Record<string, string> | null,
): Promise<OutputChunk> {
// When plugins are a function we can assume that plugins are instantiated separately
// and have a separate state. This means that we can run the worker builds in parallel.
const canRunParallel = typeof config.worker.plugins === 'function'

if (canRunParallel) {
return bundleWorkerEntry(config, id, query)
}

const processing = workerConfigSemaphore.get(config)
if (processing) {
await processing
return bundleWorkerEntry(config, id, query)
}
const promise = serialBundleWorkerEntry(config, id, query)
const promise = bundleWorkerEntry(config, id, query)
workerConfigSemaphore.set(config, promise)
promise.then(() => workerConfigSemaphore.delete(config))
return promise
}

async function serialBundleWorkerEntry(
async function bundleWorkerEntry(
config: ResolvedConfig,
id: string,
query: Record<string, string> | null,
Expand All @@ -75,7 +82,7 @@ async function serialBundleWorkerEntry(
const bundle = await rollup({
...rollupOptions,
input: cleanUrl(id),
plugins,
plugins: typeof plugins === 'function' ? await plugins() : plugins,
onwarn(warning, warn) {
onRollupWarning(warning, warn, config)
},
Expand Down Expand Up @@ -173,7 +180,7 @@ export async function workerFileToUrl(
const workerMap = workerCache.get(config.mainConfig || config)!
let fileName = workerMap.bundle.get(id)
if (!fileName) {
const outputChunk = await bundleWorkerEntry(config, id, query)
const outputChunk = await handleBundleWorkerEntry(config, id, query)
fileName = outputChunk.fileName
saveEmitWorkerAsset(config, {
fileName,
Expand Down
2 changes: 1 addition & 1 deletion playground/worker/__tests__/es/es-worker.spec.ts
Expand Up @@ -61,7 +61,7 @@ describe.runIf(isBuild)('build', () => {
test('inlined code generation', async () => {
const assetsDir = path.resolve(testDir, 'dist/es/assets')
const files = fs.readdirSync(assetsDir)
expect(files.length).toBe(28)
expect(files.length).toBe(29)
const index = files.find((f) => f.includes('main-module'))
const content = fs.readFileSync(path.resolve(assetsDir, index), 'utf-8')
const worker = files.find((f) => f.includes('my-worker'))
Expand Down
35 changes: 35 additions & 0 deletions playground/worker/__tests__/parallel/parallel-worker-build.spec.ts
@@ -0,0 +1,35 @@
import { describe, test } from 'vitest'
import { isBuild, page, untilUpdated } from '~utils'

test('deeply nested workers', async () => {
await untilUpdated(
async () => page.textContent('.deeply-nested-worker'),
/Hello\sfrom\sroot.*\/parallel\/.+deeply-nested-worker\.js/,
true,
)
await untilUpdated(
async () => page.textContent('.deeply-nested-second-worker'),
/Hello\sfrom\ssecond.*\/parallel\/.+second-worker\.js/,
true,
)
await untilUpdated(
async () => page.textContent('.deeply-nested-third-worker'),
/Hello\sfrom\sthird.*\/parallel\/.+third-worker\.js/,
true,
)
})

describe.runIf(isBuild)('build', () => {
test('expect the plugin state to be unpolluted and match across worker builds', async () => {
await untilUpdated(
() => page.textContent('.nested-worker-plugin-state'),
'"data":1',
true,
)
await untilUpdated(
() => page.textContent('.sub-worker-plugin-state'),
'"data":1',
true,
)
})
})
26 changes: 26 additions & 0 deletions playground/worker/__tests__/serial/serial-worker-build.spec.ts
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest'
import { isBuild, page, untilUpdated } from '~utils'

describe.runIf(isBuild)('build', () => {
test('expect the plugin state to be polluted and not match across worker builds', async () => {
await untilUpdated(
() => page.textContent('.nested-worker-plugin-state'),
'"type":"workerPluginState"',
true,
)
await untilUpdated(
() => page.textContent('.sub-worker-plugin-state'),
'"type":"subWorkerPluginState"',
true,
)

const nestedWorkerPluginState = JSON.parse(
await page.textContent('.nested-worker-plugin-state'),
)
const subWorkerPluginState = JSON.parse(
await page.textContent('.sub-worker-plugin-state'),
)
// The plugin state is polluted and should have a different data value from the sub worker
expect(nestedWorkerPluginState.data).not.toEqual(subWorkerPluginState.data)
})
})
19 changes: 19 additions & 0 deletions playground/worker/deeply-nested-second-worker.js
@@ -0,0 +1,19 @@
self.postMessage({
type: 'deeplyNestedSecondWorker',
data: [
'Hello from second level nested worker',
import.meta.env.BASE_URL,
self.location.url,
import.meta.url,
].join(' '),
})

const deeplyNestedThirdWorker = new Worker(
new URL('deeply-nested-third-worker.js', import.meta.url),
{ type: 'module' },
)
deeplyNestedThirdWorker.addEventListener('message', (ev) => {
self.postMessage(ev.data)
})

console.log('deeply-nested-second-worker.js')
11 changes: 11 additions & 0 deletions playground/worker/deeply-nested-third-worker.js
@@ -0,0 +1,11 @@
self.postMessage({
type: 'deeplyNestedThirdWorker',
data: [
'Hello from third level nested worker',
import.meta.env.BASE_URL,
self.location.url,
import.meta.url,
].join(' '),
})

console.log('deeply-nested-third-worker.js')
19 changes: 19 additions & 0 deletions playground/worker/deeply-nested-worker.js
@@ -0,0 +1,19 @@
self.postMessage({
type: 'deeplyNestedWorker',
data: [
'Hello from root worker',
import.meta.env.BASE_URL,
self.location.url,
import.meta.url,
].join(' '),
})

const deeplyNestedSecondWorker = new Worker(
new URL('deeply-nested-second-worker.js', import.meta.url),
{ type: 'module' },
)
deeplyNestedSecondWorker.addEventListener('message', (ev) => {
self.postMessage(ev.data)
})

console.log('deeply-nested-worker.js')