webpack-target-webextension
Version:
WebExtension plugin for Webpack. Supports code-splitting and dynamic import.
241 lines (229 loc) • 9.04 kB
JavaScript
// @ts-check
module.exports = class WebExtensionContentScriptEntryPlugin {
/**
* @param {import('../../index.js').WebExtensionPluginOptions} options
*/
constructor(options) {
this.options = options
}
/** @param {import('webpack').Compiler} compiler */
apply(compiler) {
const { WebpackError, sources, Template } = compiler.webpack
const { experimental_output } = this.options
if (!experimental_output) return
{
const sw = this.options.background?.serviceWorkerEntry
if (sw && sw in experimental_output && typeof experimental_output[sw] === 'function') {
throw new Error(
`[webpack-extension-target] options.experimental_output[${JSON.stringify(
sw,
)}] cannot be a function because it is a service worker entry. Use { file, touch(manifest, file) { manifest.background.service_worker = file; } } instead.`,
)
}
}
compiler.hooks.thisCompilation.tap(WebExtensionContentScriptEntryPlugin.name, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: WebExtensionContentScriptEntryPlugin.name,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DERIVED,
},
(assets) => {
let manifest
const serviceWorkerEntry = this.options.background?.serviceWorkerEntry
{
if (
serviceWorkerEntry &&
!(serviceWorkerEntry in experimental_output) &&
(getInitialFiles(compilation, serviceWorkerEntry)?.length || 0) > 1
) {
const e = new WebpackError(
`[webpack-extension-target] Entry ${JSON.stringify(
serviceWorkerEntry,
)} is not specified in options.experimental_output.`,
)
e.stack = ''
compilation.warnings.push(e)
}
}
for (const entry in experimental_output) {
const isBackgroundEntry = entry === serviceWorkerEntry
const entryOption = experimental_output[entry]
const initialFiles = getInitialFiles(compilation, entry)
if (!initialFiles || initialFiles.length === 0) {
const name = JSON.stringify(entry)
const e = new WebpackError(
initialFiles
? `[webpack-extension-target] Entry ${name} does not emit any initial file (specified in options.experimental_output).`
: `[webpack-extension-target] Entry ${name} does not exist (specified in options.experimental_output).`,
)
e.stack = ''
compilation.errors.push(e)
continue
}
if (entryOption === false) {
if (initialFiles.length > 1) {
const name = JSON.stringify(entry)
const e = new WebpackError(
`[webpack-extension-target] Entry ${name} emits more than one initial file which is prohibited (specified in options.experimental_output).`,
)
e.stack = ''
compilation.errors.push(e)
}
continue
}
function emitFile(/** @type {string} */ entryOption, /** @type {string[]} */ initialFiles) {
if (entryOption in assets) {
const e = new WebpackError(
`[webpack-extension-target] Cannot override an existing file ${JSON.stringify(
entryOption,
)} (specified by options.experimental_output[${JSON.stringify(entry)}]).`,
)
e.stack = ''
compilation.errors.push(e)
return
}
/** @type {string[]} */
let code
if (isBackgroundEntry) {
const asyncAndSyncFiles = getInitialAndAsyncFiles(compilation, entry)
if (compilation.outputOptions.chunkFormat === 'module') {
code = asyncAndSyncFiles.map((file) => `import ${JSON.stringify('./' + file)};`)
} else {
code = [
'try {',
Template.indent(
'importScripts(' + asyncAndSyncFiles.map((file) => JSON.stringify(file)).join(', ') + ');',
),
'} catch (e) {',
Template.indent('Promise.reject(e);'),
'}',
]
}
} else {
code = [
';(() => {',
Template.indent([
'const getURL = typeof browser === "object" ? browser.runtime.getURL : chrome.runtime.getURL;',
`${JSON.stringify(initialFiles)}.forEach(file => import(getURL(file)));`,
]),
'})();',
'null;',
]
}
const source = new compiler.webpack.sources.RawSource(Template.asString(code))
compilation.emitAsset(entryOption, source)
return
}
if (typeof entryOption === 'string') {
emitFile(entryOption, initialFiles)
continue
}
if (!manifest) {
const manifestAsset = assets['manifest.json']
const name = JSON.stringify(entry)
if (!manifestAsset) {
const e = new WebpackError(
`[webpack-extension-target] A manifest.json is required (required by options.experimental_output[${name}]). You can emit this file by using CopyPlugin or any other plugins.`,
)
e.stack = ''
compilation.errors.push(e)
continue
}
try {
const source = manifestAsset.source()
if (typeof source === 'string') manifest = JSON.parse(source)
else manifest = JSON.parse(source.toString('utf-8'))
} catch {
const e = new WebpackError(
`[webpack-extension-target] Failed to parse manifest.json (required by options.experimental_output[${name}]).`,
)
e.stack = ''
e.file = 'manifest.json'
compilation.errors.push(e)
continue
}
}
if (typeof entryOption === 'function') {
entryOption(manifest, initialFiles)
} else if (typeof entryOption === 'object') {
emitFile(entryOption.file, initialFiles)
entryOption.touch(manifest, entryOption.file)
}
}
if (manifest) {
// TODO: JSON.stringify may throw
compilation.updateAsset('manifest.json', new sources.RawSource(JSON.stringify(manifest, undefined, 4)))
}
},
)
})
}
}
/**
*
* @param {import('webpack').Compilation} compilation
* @param {string} entry
*/
function getInitialFiles(compilation, entry) {
const entryPoint = compilation.entrypoints.get(entry)
if (!entryPoint) return undefined
/** @type {Set<string>} */
const files = new Set()
const runtimeChunk = entryPoint.getRuntimeChunk()
if (runtimeChunk) {
runtimeChunk.files.forEach((file) => {
if (!isJSFile(file)) return
const asset = compilation.getAsset(file)
if (!asset) return
if (!asset.info.hotModuleReplacement) files.add(file)
})
}
entryPoint.getFiles().forEach((file) => {
if (!isJSFile(file)) return
const asset = compilation.getAsset(file)
if (!asset) return
if (!asset.info.hotModuleReplacement) files.add(file)
})
return [...files]
}
/**
*
* @param {import('@rspack/core').Compilation | import('webpack').Compilation} compilation
* @param {string} entry
*/
function getInitialAndAsyncFiles(compilation, entry) {
const entryPoint = compilation.entrypoints.get(entry)
if (!entryPoint) return []
/** @type {Set<string>} */
const files = new Set()
const visitedChunk = new Set()
/** @param {import('@rspack/core').Chunk | import('webpack').Chunk} chunk */
function visit(chunk) {
const chunkId =
'rspack' in compilation.compiler
? !chunk.id || !chunk.hash
? Array.from(chunk.files).join('')
: chunk.id + chunk.hash
: chunk
if (visitedChunk.has(chunkId)) return
visitedChunk.add(chunkId)
chunk.files.forEach((file) => {
if (!isJSFile(file)) return
const asset = compilation.getAsset(file)
if (!asset) return
if (!asset.info.hotModuleReplacement) files.add(file)
})
for (const child of chunk.getAllAsyncChunks()) {
visit(child)
}
}
entryPoint.chunks.forEach(visit)
const allFiles = [...files].filter((file) => isJSFile(file) && compilation.getAsset(file))
return allFiles
}
/**
* @param {string} file
*/
function isJSFile(file) {
return file.endsWith('.js') || file.endsWith('.mjs')
}