Skip to content

Commit

Permalink
feat(worker): support dynamic worker option fields (#19010)
Browse files Browse the repository at this point in the history
  • Loading branch information
jamsinclair authored Jan 23, 2025
1 parent caad985 commit d0c3523
Show file tree
Hide file tree
Showing 3 changed files with 254 additions and 10 deletions.
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))"`,
)
})

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 */
name: genWorkerName(),
type: 'module',
},
Expand Down

0 comments on commit d0c3523

Please sign in to comment.