diff --git a/packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts b/packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts new file mode 100644 index 00000000000000..201946b99207c0 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'vitest' +import { parseAst } from 'rollup/parseAst' +import { workerImportMetaUrlPlugin } from '../../plugins/workerImportMetaUrl' +import { resolveConfig } from '../../config' +import { PartialEnvironment } from '../../baseEnvironment' + +async function createWorkerImportMetaUrlPluginTransform() { + const config = await resolveConfig({ configFile: false }, 'serve') + const instance = workerImportMetaUrlPlugin(config) + const environment = new PartialEnvironment('client', config) + + return async (code: string) => { + // @ts-expect-error transform should exist + const result = await instance.transform.call( + { environment, parse: parseAst }, + code, + 'foo.ts', + ) + return result?.code || result + } +} + +describe('workerImportMetaUrlPlugin', async () => { + const transform = await createWorkerImportMetaUrlPluginTransform() + + test('without worker options', async () => { + expect( + await transform('new Worker(new URL("./worker.js", import.meta.url))'), + ).toMatchInlineSnapshot( + `"new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=classic", import.meta.url))"`, + ) + }) + + test('with shared worker', async () => { + expect( + await transform( + 'new SharedWorker(new URL("./worker.js", import.meta.url))', + ), + ).toMatchInlineSnapshot( + `"new SharedWorker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=classic", import.meta.url))"`, + ) + }) + + test('with static worker options and identifier properties', async () => { + expect( + await transform( + 'new Worker(new URL("./worker.js", import.meta.url), { type: "module", name: "worker1" })', + ), + ).toMatchInlineSnapshot( + `"new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=module", import.meta.url), { type: "module", name: "worker1" })"`, + ) + }) + + test('with static worker options and literal properties', async () => { + expect( + await transform( + 'new Worker(new URL("./worker.js", import.meta.url), { "type": "module", "name": "worker1" })', + ), + ).toMatchInlineSnapshot( + `"new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=module", import.meta.url), { "type": "module", "name": "worker1" })"`, + ) + }) + + test('with dynamic name field in worker options', async () => { + expect( + await transform( + 'const id = 1; new Worker(new URL("./worker.js", import.meta.url), { name: "worker" + id })', + ), + ).toMatchInlineSnapshot( + `"const id = 1; new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=classic", import.meta.url), { name: "worker" + id })"`, + ) + }) + + test('with dynamic name field and static type in worker options', async () => { + expect( + await transform( + 'const id = 1; new Worker(new URL("./worker.js", import.meta.url), { name: "worker" + id, type: "module" })', + ), + ).toMatchInlineSnapshot( + `"const id = 1; new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=module", import.meta.url), { name: "worker" + id, type: "module" })"`, + ) + }) + + test('with parenthesis inside of worker options', async () => { + expect( + await transform( + 'const worker = new Worker(new URL("./worker.js", import.meta.url), { name: genName(), type: "module"})', + ), + ).toMatchInlineSnapshot( + `"const worker = new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=module", import.meta.url), { name: genName(), type: "module"})"`, + ) + }) + + test('with multi-line code and worker options', async () => { + expect( + await transform(` +const worker = new Worker(new URL("./worker.js", import.meta.url), { + name: genName(), + type: "module", + }, +) + +worker.addEventListener('message', (ev) => text('.simple-worker-url', JSON.stringify(ev.data))) +`), + ).toMatchInlineSnapshot(`" +const worker = new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=module", import.meta.url), { + name: genName(), + type: "module", + }, +) + +worker.addEventListener('message', (ev) => text('.simple-worker-url', JSON.stringify(ev.data))) +"`) + }) + + test('throws an error when non-static worker options are provided', async () => { + await expect( + transform( + 'new Worker(new URL("./worker.js", import.meta.url), myWorkerOptions)', + ), + ).rejects.toThrow( + 'Vite is unable to parse the worker options as the value is not static. To ignore this error, please use /* @vite-ignore */ in the worker options.', + ) + }) + + test('throws an error when worker options are not an object', async () => { + await expect( + transform( + 'new Worker(new URL("./worker.js", import.meta.url), "notAnObject")', + ), + ).rejects.toThrow('Expected worker options to be an object, got string') + }) + + test('throws an error when non-literal type field in worker options', async () => { + await expect( + transform( + 'const type = "module"; new Worker(new URL("./worker.js", import.meta.url), { type })', + ), + ).rejects.toThrow( + 'Expected worker options type property to be a literal value.', + ) + }) + + test('throws an error when spread operator used without the type field', async () => { + await expect( + transform( + 'const options = { name: "worker1" }; new Worker(new URL("./worker.js", import.meta.url), { ...options })', + ), + ).rejects.toThrow( + 'Expected object spread to be used before the definition of the type property. Vite needs a static value for the type property to correctly infer it.', + ) + }) + + test('throws an error when spread operator used after definition of type field', async () => { + await expect( + transform( + 'const options = { name: "worker1" }; new Worker(new URL("./worker.js", import.meta.url), { type: "module", ...options })', + ), + ).rejects.toThrow( + 'Expected object spread to be used before the definition of the type property. Vite needs a static value for the type property to correctly infer it.', + ) + }) +}) diff --git a/packages/vite/src/node/plugins/workerImportMetaUrl.ts b/packages/vite/src/node/plugins/workerImportMetaUrl.ts index 8d8d316a4ec214..9c85e458ef961c 100644 --- a/packages/vite/src/node/plugins/workerImportMetaUrl.ts +++ b/packages/vite/src/node/plugins/workerImportMetaUrl.ts @@ -1,7 +1,9 @@ import path from 'node:path' import MagicString from 'magic-string' -import type { RollupError } from 'rollup' +import type { RollupAstNode, RollupError } from 'rollup' +import { parseAstAsync } from 'rollup/parseAst' import { stripLiteral } from 'strip-literal' +import type { Expression, ExpressionStatement } from 'estree' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' import { evalValue, injectQuery, transformStableResult } from '../utils' @@ -25,16 +27,92 @@ function err(e: string, pos: number) { return error } -function parseWorkerOptions( +function findClosingParen(input: string, fromIndex: number) { + let count = 1 + + for (let i = fromIndex + 1; i < input.length; i++) { + if (input[i] === '(') count++ + if (input[i] === ')') count-- + if (count === 0) return i + } + + return -1 +} + +function extractWorkerTypeFromAst( + expression: Expression, + optsStartIndex: number, +): 'classic' | 'module' | undefined { + if (expression.type !== 'ObjectExpression') { + return + } + + let lastSpreadElementIndex = -1 + let typeProperty = null + let typePropertyIndex = -1 + + for (let i = 0; i < expression.properties.length; i++) { + const property = expression.properties[i] + + if (property.type === 'SpreadElement') { + lastSpreadElementIndex = i + continue + } + + if ( + property.type === 'Property' && + ((property.key.type === 'Identifier' && property.key.name === 'type') || + (property.key.type === 'Literal' && property.key.value === 'type')) + ) { + typeProperty = property + typePropertyIndex = i + } + } + + if (typePropertyIndex === -1 && lastSpreadElementIndex === -1) { + // No type property or spread element in use. Assume safe usage and default to classic + return 'classic' + } + + if (typePropertyIndex < lastSpreadElementIndex) { + throw err( + 'Expected object spread to be used before the definition of the type property. ' + + 'Vite needs a static value for the type property to correctly infer it.', + optsStartIndex, + ) + } + + if (typeProperty?.value.type !== 'Literal') { + throw err( + 'Expected worker options type property to be a literal value.', + optsStartIndex, + ) + } + + // Silently default to classic type like the getWorkerType method + return typeProperty?.value.value === 'module' ? 'module' : 'classic' +} + +async function parseWorkerOptions( rawOpts: string, optsStartIndex: number, -): WorkerOptions { +): Promise { let opts: WorkerOptions = {} try { opts = evalValue(rawOpts) } catch { + const optsNode = ( + (await parseAstAsync(`(${rawOpts})`)) + .body[0] as RollupAstNode + ).expression + + const type = extractWorkerTypeFromAst(optsNode, optsStartIndex) + if (type) { + return { type } + } + throw err( - 'Vite is unable to parse the worker options as the value is not static.' + + 'Vite is unable to parse the worker options as the value is not static. ' + 'To ignore this error, please use /* @vite-ignore */ in the worker options.', optsStartIndex, ) @@ -54,12 +132,16 @@ function parseWorkerOptions( return opts } -function getWorkerType(raw: string, clean: string, i: number): WorkerType { +async function getWorkerType( + raw: string, + clean: string, + i: number, +): Promise { const commaIndex = clean.indexOf(',', i) if (commaIndex === -1) { return 'classic' } - const endIndex = clean.indexOf(')', i) + const endIndex = findClosingParen(clean, i) // case: ') ... ,' mean no worker options params if (commaIndex > endIndex) { @@ -82,7 +164,7 @@ function getWorkerType(raw: string, clean: string, i: number): WorkerType { return 'classic' } - const workerOpts = parseWorkerOptions(workerOptString, commaIndex + 1) + const workerOpts = await parseWorkerOptions(workerOptString, commaIndex + 1) if ( workerOpts.type && (workerOpts.type === 'module' || workerOpts.type === 'classic') @@ -152,12 +234,12 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { } s ||= new MagicString(code) - const workerType = getWorkerType(code, cleanString, endIndex) + const workerType = await getWorkerType(code, cleanString, endIndex) const url = rawUrl.slice(1, -1) let file: string | undefined if (url[0] === '.') { file = path.resolve(path.dirname(id), url) - file = tryFsResolve(file, fsResolveOptions) ?? file + file = slash(tryFsResolve(file, fsResolveOptions) ?? file) } else { workerResolver ??= createBackCompatIdResolver(config, { extensions: [], diff --git a/playground/worker/worker/main-module.js b/playground/worker/worker/main-module.js index ed694895296f0e..a659aa438fd5ca 100644 --- a/playground/worker/worker/main-module.js +++ b/playground/worker/worker/main-module.js @@ -140,7 +140,6 @@ const genWorkerName = () => 'module' const w2 = new SharedWorker( new URL('../url-shared-worker.js', import.meta.url), { - /* @vite-ignore */ name: genWorkerName(), type: 'module', },