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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(worker): support dynamic worker option fields #19010

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
163 changes: 163 additions & 0 deletions packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -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))"`,
hi-ogawa marked this conversation as resolved.
Show resolved Hide resolved
)
})

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.',
)
})
})
100 changes: 91 additions & 9 deletions packages/vite/src/node/plugins/workerImportMetaUrl.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<WorkerOptions> {
let opts: WorkerOptions = {}
try {
opts = evalValue<WorkerOptions>(rawOpts)
} catch {
const optsNode = (
(await parseAstAsync(`(${rawOpts})`))
.body[0] as RollupAstNode<ExpressionStatement>
).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,
)
Expand All @@ -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<WorkerType> {
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) {
Expand All @@ -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')
Expand Down Expand Up @@ -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: [],
Expand Down
1 change: 0 additions & 1 deletion playground/worker/worker/main-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ const genWorkerName = () => 'module'
const w2 = new SharedWorker(
new URL('../url-shared-worker.js', import.meta.url),
{
/* @vite-ignore */
jamsinclair marked this conversation as resolved.
Show resolved Hide resolved
name: genWorkerName(),
type: 'module',
},
Expand Down
Loading