diff --git a/packages/playground/worker/__tests__/sourcemap-hidden/sourcemap-hidden-worker.spec.ts b/packages/playground/worker/__tests__/sourcemap-hidden/sourcemap-hidden-worker.spec.ts new file mode 100644 index 00000000000000..d846a5de2311d0 --- /dev/null +++ b/packages/playground/worker/__tests__/sourcemap-hidden/sourcemap-hidden-worker.spec.ts @@ -0,0 +1,129 @@ +import fs from 'fs' +import path from 'path' +import { untilUpdated, isBuild, testDir } from '../../../testUtils' +import { Page } from 'playwright-chromium' + +if (isBuild) { + const assetsDir = path.resolve(testDir, 'dist/iife-sourcemap-hidden/assets') + // assert correct files + test('sourcemap generation for web workers', async () => { + const files = fs.readdirSync(assetsDir) + // should have 2 worker chunk + expect(files.length).toBe(25) + const index = files.find((f) => f.includes('main-module')) + const content = fs.readFileSync(path.resolve(assetsDir, index), 'utf-8') + const indexSourcemap = getSourceMapUrl(content) + const worker = files.find((f) => /^my-worker\.\w+\.js$/.test(f)) + const workerContent = fs.readFileSync( + path.resolve(assetsDir, worker), + 'utf-8' + ) + const workerSourcemap = getSourceMapUrl(workerContent) + const sharedWorker = files.find((f) => + /^my-shared-worker\.\w+\.js$/.test(f) + ) + const sharedWorkerContent = fs.readFileSync( + path.resolve(assetsDir, sharedWorker), + 'utf-8' + ) + const sharedWorkerSourcemap = getSourceMapUrl(sharedWorkerContent) + const possibleTsOutputWorker = files.find((f) => + /^possible-ts-output-worker\.\w+\.js$/.test(f) + ) + const possibleTsOutputWorkerContent = fs.readFileSync( + path.resolve(assetsDir, possibleTsOutputWorker), + 'utf-8' + ) + const possibleTsOutputWorkerSourcemap = getSourceMapUrl( + possibleTsOutputWorkerContent + ) + const workerNestedWorker = files.find((f) => + /^worker-nested-worker\.\w+\.js$/.test(f) + ) + const workerNestedWorkerContent = fs.readFileSync( + path.resolve(assetsDir, workerNestedWorker), + 'utf-8' + ) + const workerNestedWorkerSourcemap = getSourceMapUrl( + workerNestedWorkerContent + ) + const subWorker = files.find((f) => /^sub-worker\.\w+\.js$/.test(f)) + const subWorkerContent = fs.readFileSync( + path.resolve(assetsDir, subWorker), + 'utf-8' + ) + const subWorkerSourcemap = getSourceMapUrl(subWorkerContent) + + expect(files).toContainEqual(expect.stringMatching(/^index\.\w+\.js\.map$/)) + expect(files).toContainEqual( + expect.stringMatching(/^my-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^my-shared-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^possible-ts-output-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^worker-nested-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^sub-worker\.\w+\.js\.map$/) + ) + + // sourcemap should exist and have a data URL + expect(indexSourcemap).toBe(null) + expect(workerSourcemap).toBe(null) + expect(sharedWorkerSourcemap).toBe(null) + expect(possibleTsOutputWorkerSourcemap).toBe(null) + expect(workerNestedWorkerSourcemap).toBe(null) + expect(subWorkerSourcemap).toBe(null) + + // worker should have all imports resolved and no exports + expect(workerContent).not.toMatch(`import`) + expect(workerContent).not.toMatch(`export`) + + // shared worker should have all imports resolved and no exports + expect(sharedWorkerContent).not.toMatch(`import`) + expect(sharedWorkerContent).not.toMatch(`export`) + + // chunk + expect(content).toMatch( + `new Worker("/iife-sourcemap-hidden/assets/my-worker` + ) + expect(content).toMatch(`new Worker("data:application/javascript;base64`) + expect(content).toMatch( + `new Worker("/iife-sourcemap-hidden/assets/possible-ts-output-worker` + ) + expect(content).toMatch( + `new Worker("/iife-sourcemap-hidden/assets/worker-nested-worker` + ) + expect(content).toMatch( + `new SharedWorker("/iife-sourcemap-hidden/assets/my-shared-worker` + ) + + // inlined + expect(content).toMatch(`(window.URL||window.webkitURL).createObjectURL`) + expect(content).toMatch(`window.Blob`) + + expect(workerNestedWorkerContent).toMatch( + `new Worker("/iife-sourcemap-hidden/assets/sub-worker` + ) + }) +} else { + // Workaround so that testing serve does not emit + // "Your test suite must contain at least one test" + test('true', () => { + expect(true).toBe(true) + }) +} + +function getSourceMapUrl(code: string): string { + const regex = /\/\/[#@]\s(?:source(?:Mapping)?URL)=\s*(\S+)/g + const results = regex.exec(code) + + if (results && results.length >= 2) { + return results[1] + } + return null +} diff --git a/packages/playground/worker/__tests__/sourcemap-hidden/vite.config.js b/packages/playground/worker/__tests__/sourcemap-hidden/vite.config.js new file mode 100644 index 00000000000000..d51907577e9deb --- /dev/null +++ b/packages/playground/worker/__tests__/sourcemap-hidden/vite.config.js @@ -0,0 +1 @@ +module.exports = require('../../vite.config-sourcemap')('hidden') diff --git a/packages/playground/worker/__tests__/sourcemap-inline/sourcemap-inline-worker.spec.ts b/packages/playground/worker/__tests__/sourcemap-inline/sourcemap-inline-worker.spec.ts new file mode 100644 index 00000000000000..ceda7dae1fec7c --- /dev/null +++ b/packages/playground/worker/__tests__/sourcemap-inline/sourcemap-inline-worker.spec.ts @@ -0,0 +1,112 @@ +import fs from 'fs' +import path from 'path' +import { untilUpdated, isBuild, testDir } from '../../../testUtils' +import { Page } from 'playwright-chromium' + +if (isBuild) { + const assetsDir = path.resolve(testDir, 'dist/iife-sourcemap-inline/assets') + // assert correct files + test('sourcemap generation for web workers', async () => { + const files = fs.readdirSync(assetsDir) + // should have 2 worker chunk + expect(files.length).toBe(13) + const index = files.find((f) => f.includes('main-module')) + const content = fs.readFileSync(path.resolve(assetsDir, index), 'utf-8') + const indexSourcemap = getSourceMapUrl(content) + const worker = files.find((f) => /^my-worker\.\w+\.js$/.test(f)) + const workerContent = fs.readFileSync( + path.resolve(assetsDir, worker), + 'utf-8' + ) + const workerSourcemap = getSourceMapUrl(workerContent) + const sharedWorker = files.find((f) => + /^my-shared-worker\.\w+\.js$/.test(f) + ) + const sharedWorkerContent = fs.readFileSync( + path.resolve(assetsDir, sharedWorker), + 'utf-8' + ) + const sharedWorkerSourcemap = getSourceMapUrl(sharedWorkerContent) + const possibleTsOutputWorker = files.find((f) => + /^possible-ts-output-worker\.\w+\.js$/.test(f) + ) + const possibleTsOutputWorkerContent = fs.readFileSync( + path.resolve(assetsDir, possibleTsOutputWorker), + 'utf-8' + ) + const possibleTsOutputWorkerSourcemap = getSourceMapUrl( + possibleTsOutputWorkerContent + ) + const workerNestedWorker = files.find((f) => + /^worker-nested-worker\.\w+\.js$/.test(f) + ) + const workerNestedWorkerContent = fs.readFileSync( + path.resolve(assetsDir, workerNestedWorker), + 'utf-8' + ) + const workerNestedWorkerSourcemap = getSourceMapUrl( + workerNestedWorkerContent + ) + const subWorker = files.find((f) => /^sub-worker\.\w+\.js$/.test(f)) + const subWorkerContent = fs.readFileSync( + path.resolve(assetsDir, subWorker), + 'utf-8' + ) + const subWorkerSourcemap = getSourceMapUrl(subWorkerContent) + + // sourcemap should exist and have a data URL + expect(indexSourcemap).toMatch(/^data:/) + expect(workerSourcemap).toMatch(/^data:/) + expect(sharedWorkerSourcemap).toMatch(/^data:/) + expect(possibleTsOutputWorkerSourcemap).toMatch(/^data:/) + expect(workerNestedWorkerSourcemap).toMatch(/^data:/) + expect(subWorkerSourcemap).toMatch(/^data:/) + + // worker should have all imports resolved and no exports + expect(workerContent).not.toMatch(`import`) + expect(workerContent).not.toMatch(`export`) + + // shared worker should have all imports resolved and no exports + expect(sharedWorkerContent).not.toMatch(`import`) + expect(sharedWorkerContent).not.toMatch(`export`) + + // chunk + expect(content).toMatch( + `new Worker("/iife-sourcemap-inline/assets/my-worker` + ) + expect(content).toMatch(`new Worker("data:application/javascript;base64`) + expect(content).toMatch( + `new Worker("/iife-sourcemap-inline/assets/possible-ts-output-worker` + ) + expect(content).toMatch( + `new Worker("/iife-sourcemap-inline/assets/worker-nested-worker` + ) + expect(content).toMatch( + `new SharedWorker("/iife-sourcemap-inline/assets/my-shared-worker` + ) + + // inlined + expect(content).toMatch(`(window.URL||window.webkitURL).createObjectURL`) + expect(content).toMatch(`window.Blob`) + + expect(workerNestedWorkerContent).toMatch( + `new Worker("/iife-sourcemap-inline/assets/sub-worker` + ) + }) +} else { + // Workaround so that testing serve does not emit + // "Your test suite must contain at least one test" + test('true', () => { + expect(true).toBe(true) + }) +} + +function getSourceMapUrl(code: string): string { + const regex = /\/\/[#@]\s(?:source(?:Mapping)?URL)=\s*(\S+)/g + const results = regex.exec(code) + + if (results && results.length >= 2) { + return results[1] + } + return null +} diff --git a/packages/playground/worker/__tests__/sourcemap-inline/vite.config.js b/packages/playground/worker/__tests__/sourcemap-inline/vite.config.js new file mode 100644 index 00000000000000..abe37cd56accd6 --- /dev/null +++ b/packages/playground/worker/__tests__/sourcemap-inline/vite.config.js @@ -0,0 +1 @@ +module.exports = require('../../vite.config-sourcemap')('inline') diff --git a/packages/playground/worker/__tests__/sourcemap/sourcemap-worker.spec.ts b/packages/playground/worker/__tests__/sourcemap/sourcemap-worker.spec.ts new file mode 100644 index 00000000000000..54e4f1cb9f2d58 --- /dev/null +++ b/packages/playground/worker/__tests__/sourcemap/sourcemap-worker.spec.ts @@ -0,0 +1,131 @@ +import fs from 'fs' +import path from 'path' +import { untilUpdated, isBuild, testDir } from '../../../testUtils' +import { Page } from 'playwright-chromium' + +if (isBuild) { + const assetsDir = path.resolve(testDir, 'dist/iife-sourcemap/assets') + // assert correct files + test('sourcemap generation for web workers', async () => { + const files = fs.readdirSync(assetsDir) + // should have 2 worker chunk + expect(files.length).toBe(25) + const index = files.find((f) => f.includes('main-module')) + const content = fs.readFileSync(path.resolve(assetsDir, index), 'utf-8') + const indexSourcemap = getSourceMapUrl(content) + const worker = files.find((f) => /^my-worker\.\w+\.js$/.test(f)) + const workerContent = fs.readFileSync( + path.resolve(assetsDir, worker), + 'utf-8' + ) + const workerSourcemap = getSourceMapUrl(workerContent) + const sharedWorker = files.find((f) => + /^my-shared-worker\.\w+\.js$/.test(f) + ) + const sharedWorkerContent = fs.readFileSync( + path.resolve(assetsDir, sharedWorker), + 'utf-8' + ) + const sharedWorkerSourcemap = getSourceMapUrl(sharedWorkerContent) + const possibleTsOutputWorker = files.find((f) => + /^possible-ts-output-worker\.\w+\.js$/.test(f) + ) + const possibleTsOutputWorkerContent = fs.readFileSync( + path.resolve(assetsDir, possibleTsOutputWorker), + 'utf-8' + ) + const possibleTsOutputWorkerSourcemap = getSourceMapUrl( + possibleTsOutputWorkerContent + ) + const workerNestedWorker = files.find((f) => + /^worker-nested-worker\.\w+\.js$/.test(f) + ) + const workerNestedWorkerContent = fs.readFileSync( + path.resolve(assetsDir, workerNestedWorker), + 'utf-8' + ) + const workerNestedWorkerSourcemap = getSourceMapUrl( + workerNestedWorkerContent + ) + const subWorker = files.find((f) => /^sub-worker\.\w+\.js$/.test(f)) + const subWorkerContent = fs.readFileSync( + path.resolve(assetsDir, subWorker), + 'utf-8' + ) + const subWorkerSourcemap = getSourceMapUrl(subWorkerContent) + + expect(files).toContainEqual(expect.stringMatching(/^index\.\w+\.js\.map$/)) + expect(files).toContainEqual( + expect.stringMatching(/^my-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^my-shared-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^possible-ts-output-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^worker-nested-worker\.\w+\.js\.map$/) + ) + expect(files).toContainEqual( + expect.stringMatching(/^sub-worker\.\w+\.js\.map$/) + ) + + // sourcemap should exist and have a data URL + expect(indexSourcemap).toMatch(/^main-module\.\w+\.js\.map$/) + expect(workerSourcemap).toMatch(/^my-worker\.\w+\.js\.map$/) + expect(sharedWorkerSourcemap).toMatch(/^my-shared-worker\.\w+\.js\.map$/) + expect(possibleTsOutputWorkerSourcemap).toMatch( + /^possible-ts-output-worker\.\w+\.js\.map$/ + ) + expect(workerNestedWorkerSourcemap).toMatch( + /^worker-nested-worker\.\w+\.js\.map$/ + ) + expect(subWorkerSourcemap).toMatch(/^sub-worker\.\w+\.js\.map$/) + + // worker should have all imports resolved and no exports + expect(workerContent).not.toMatch(`import`) + expect(workerContent).not.toMatch(`export`) + + // shared worker should have all imports resolved and no exports + expect(sharedWorkerContent).not.toMatch(`import`) + expect(sharedWorkerContent).not.toMatch(`export`) + + // chunk + expect(content).toMatch(`new Worker("/iife-sourcemap/assets/my-worker`) + expect(content).toMatch(`new Worker("data:application/javascript;base64`) + expect(content).toMatch( + `new Worker("/iife-sourcemap/assets/possible-ts-output-worker` + ) + expect(content).toMatch( + `new Worker("/iife-sourcemap/assets/worker-nested-worker` + ) + expect(content).toMatch( + `new SharedWorker("/iife-sourcemap/assets/my-shared-worker` + ) + + // inlined + expect(content).toMatch(`(window.URL||window.webkitURL).createObjectURL`) + expect(content).toMatch(`window.Blob`) + + expect(workerNestedWorkerContent).toMatch( + `new Worker("/iife-sourcemap/assets/sub-worker` + ) + }) +} else { + // Workaround so that testing serve does not emit + // "Your test suite must contain at least one test" + test('true', () => { + expect(true).toBe(true) + }) +} + +function getSourceMapUrl(code: string): string { + const regex = /\/\/[#@]\s(?:source(?:Mapping)?URL)=\s*(\S+)/g + const results = regex.exec(code) + + if (results && results.length >= 2) { + return results[1] + } + return null +} diff --git a/packages/playground/worker/__tests__/sourcemap/vite.config.js b/packages/playground/worker/__tests__/sourcemap/vite.config.js new file mode 100644 index 00000000000000..7d3aeeeb774e18 --- /dev/null +++ b/packages/playground/worker/__tests__/sourcemap/vite.config.js @@ -0,0 +1 @@ +module.exports = require('../../vite.config-sourcemap')(true) diff --git a/packages/playground/worker/__tests__/worker.spec.ts b/packages/playground/worker/__tests__/worker.spec.ts index 263c49cff52fb9..2becafc5cd9a6c 100644 --- a/packages/playground/worker/__tests__/worker.spec.ts +++ b/packages/playground/worker/__tests__/worker.spec.ts @@ -94,3 +94,13 @@ test('classic worker', async () => { expect(await page.textContent('.classic-worker')).toMatch('A classic') expect(await page.textContent('.classic-shared-worker')).toMatch('A classic') }) + +function getSourceMapUrl(code: string): string { + const regex = /\/\/[#@]\s(?:source(?:Mapping)?URL)=\s*(\S+)/g + const results = regex.exec(code) + + if (results && results.length >= 2) { + return results[1] + } + return null +} diff --git a/packages/playground/worker/vite.config-sourcemap.js b/packages/playground/worker/vite.config-sourcemap.js new file mode 100644 index 00000000000000..a8441d213a0d2d --- /dev/null +++ b/packages/playground/worker/vite.config-sourcemap.js @@ -0,0 +1,19 @@ +const vueJsx = require('@vitejs/plugin-vue-jsx') +const vite = require('vite') + +module.exports = (sourcemap) => + vite.defineConfig({ + base: `/iife-${ + typeof sourcemap === 'boolean' ? 'sourcemap' : 'sourcemap-' + sourcemap + }/`, + worker: { + format: 'iife', + plugins: [vueJsx()] + }, + build: { + outDir: `dist/iife-${ + typeof sourcemap === 'boolean' ? 'sourcemap' : 'sourcemap-' + sourcemap + }/`, + sourcemap: sourcemap + } + }) diff --git a/packages/vite/src/node/plugins/worker.ts b/packages/vite/src/node/plugins/worker.ts index 1e122d141f4898..4113b7153f9b35 100644 --- a/packages/vite/src/node/plugins/worker.ts +++ b/packages/vite/src/node/plugins/worker.ts @@ -6,7 +6,7 @@ import type Rollup from 'rollup' import { ENV_PUBLIC_PATH } from '../constants' import path from 'path' import { onRollupWarning } from '../build' -import type { EmittedFile } from 'rollup' +import type { TransformPluginContext, EmittedFile } from 'rollup' interface WorkerCache { // save worker bundle emitted files avoid overwrites the same file. @@ -69,7 +69,8 @@ function emitWorkerChunks( export async function bundleWorkerEntry( ctx: Rollup.TransformPluginContext, config: ResolvedConfig, - id: string + id: string, + query: Record | null ): Promise { // bundle the file as entry to support imports const rollup = require('rollup') as typeof Rollup @@ -83,15 +84,15 @@ export async function bundleWorkerEntry( }, preserveEntrySignatures: false }) - let code: string + let chunk: Rollup.OutputChunk try { const { - output: [outputCode, ...outputChunks] + output: [outputChunk, ...outputChunks] } = await bundle.generate({ format, sourcemap: config.build.sourcemap }) - code = outputCode.code + chunk = outputChunk outputChunks.forEach((outputChunk) => { if (outputChunk.type === 'asset') { emitWorkerAssets(ctx, config, outputChunk) @@ -109,13 +110,64 @@ export async function bundleWorkerEntry( } finally { await bundle.close() } + return emitSourcemapForWorkerEntry(ctx, config, id, query, chunk) +} + +function emitSourcemapForWorkerEntry( + context: TransformPluginContext, + config: ResolvedConfig, + id: string, + query: Record | null, + chunk: Rollup.OutputChunk +): Buffer { + let { code, map: sourcemap } = chunk + if (sourcemap) { + if (config.build.sourcemap === 'inline') { + // Manually add the sourcemap to the code if configured for inline sourcemaps. + // TODO: Remove when https://github.com/rollup/rollup/issues/3913 is resolved + // Currently seems that it won't be resolved until Rollup 3 + const dataUrl = sourcemap.toUrl() + code += `//# sourceMappingURL=${dataUrl}` + } else if ( + config.build.sourcemap === 'hidden' || + config.build.sourcemap === true + ) { + const basename = path.parse(cleanUrl(id)).name + const data = sourcemap.toString() + const content = Buffer.from(data) + const contentHash = getAssetHash(content) + const fileName = `${basename}.${contentHash}.js.map` + const filePath = path.posix.join(config.build.assetsDir, fileName) + if (!context.cache.has(contentHash)) { + context.cache.set(contentHash, true) + context.emitFile({ + fileName: filePath, + type: 'asset', + source: data + }) + } + + // Emit the comment that tells the JS debugger where it can find the + // sourcemap file. + // 'hidden' causes the sourcemap file to be created but + // the comment in the file to be omitted. + if (config.build.sourcemap === true) { + // inline web workers need to use the full sourcemap path + // non-inline web workers can use a relative path + const sourceMapUrl = query?.inline != null ? filePath : fileName + code += `//# sourceMappingURL=${sourceMapUrl}` + } + } + } + return Buffer.from(code) } export async function workerFileToUrl( ctx: Rollup.TransformPluginContext, config: ResolvedConfig, - id: string + id: string, + query: Record | null ): Promise { const workerMap = workerCache.get(config)! @@ -124,7 +176,7 @@ export async function workerFileToUrl( // rewrite truth id, no need to replace by asset plugin return config.base + workerMap.emitted.get(hash)! } - const code = await bundleWorkerEntry(ctx, config, id) + const code = await bundleWorkerEntry(ctx, config, id, query) const basename = path.parse(cleanUrl(id)).name const contentHash = getAssetHash(code) const fileName = path.posix.join( @@ -185,11 +237,12 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { let url: string if (isBuild) { if (query.inline != null) { - const code = await bundleWorkerEntry(this, config, id) + const code = await bundleWorkerEntry(this, config, id, query) const { format } = config.worker const workerOptions = format === 'es' ? '{type: "module"}' : '{}' // inline as blob data url - return `const encodedJs = "${code.toString('base64')}"; + return { + code: `const encodedJs = "${code.toString('base64')}"; const blob = typeof window !== "undefined" && window.Blob && new Blob([atob(encodedJs)], { type: "text/javascript;charset=utf-8" }); export default function WorkerWrapper() { const objURL = blob && (window.URL || window.webkitURL).createObjectURL(blob); @@ -198,9 +251,13 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { } finally { objURL && (window.URL || window.webkitURL).revokeObjectURL(objURL); } - }` + }`, + + // Empty sourcemap to supress Rollup warning + map: { mappings: '' } + } } else { - url = await workerFileToUrl(this, config, id) + url = await workerFileToUrl(this, config, id, query) } } else { url = await fileToUrl(cleanUrl(id), config, this) @@ -211,11 +268,14 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { query.sharedworker != null ? 'SharedWorker' : 'Worker' const workerOptions = { type: 'module' } - return `export default function WorkerWrapper() { - return new ${workerConstructor}(${JSON.stringify( - url - )}, ${JSON.stringify(workerOptions, null, 2)}) - }` + return { + code: `export default function WorkerWrapper() { + return new ${workerConstructor}(${JSON.stringify( + url + )}, ${JSON.stringify(workerOptions, null, 2)}) + }`, + map: { mappings: '' } // Empty sourcemap to supress Rolup warning + } }, renderChunk(code) { diff --git a/packages/vite/src/node/plugins/workerImportMetaUrl.ts b/packages/vite/src/node/plugins/workerImportMetaUrl.ts index 9f7a5ebf0d61ae..c9a2903d9a4142 100644 --- a/packages/vite/src/node/plugins/workerImportMetaUrl.ts +++ b/packages/vite/src/node/plugins/workerImportMetaUrl.ts @@ -162,7 +162,7 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { const file = path.resolve(path.dirname(id), rawUrl.slice(1, -1)) let url: string if (isBuild) { - url = await workerFileToUrl(this, config, file) + url = await workerFileToUrl(this, config, file, query) } else { url = await fileToUrl(cleanUrl(file), config, this) url = injectQuery(url, WORKER_FILE_ID)