vite-plugin-web-extension
Version:

1 lines • 90 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/plugins/manifest-loader-plugin.ts","../src/constants.ts","../src/logger.ts","../src/build/build-context.ts","../src/utils.ts","../src/plugins/labeled-step-plugin.ts","../src/plugins/multibuild-complete-plugin.ts","../src/plugins/bundle-tracker-plugin.ts","../src/build/getViteConfigsForInputs.ts","../src/plugins/hmr-rewrite-plugin.ts","../src/extension-runner/web-ext-runner.ts","../src/config.ts","../src/manifest-validation.ts","../src/csp.ts","../src/build/renderManifest.ts"],"sourcesContent":["import * as vite from \"vite\";\nimport { ResolvedOptions, UserOptions } from \"./options\";\nimport { manifestLoaderPlugin } from \"./plugins/manifest-loader-plugin\";\nimport fs from \"fs-extra\";\n\nexport { UserOptions as PluginOptions };\n\nexport default function webExtension(\n options: UserOptions = {}\n): vite.PluginOption {\n // Prevent recursively applying the `webExtension` plugin, see #59 and #105\n if (process.env.VITE_PLUGIN_WEB_EXTENSION_CHILD_BUILD === \"true\") {\n return [];\n }\n\n const internalOptions: ResolvedOptions = {\n additionalInputs: options.additionalInputs ?? [],\n disableAutoLaunch: options.disableAutoLaunch ?? false,\n manifest: options.manifest ?? \"manifest.json\",\n printSummary: options.printSummary ?? true,\n skipManifestValidation: options.skipManifestValidation ?? false,\n watchFilePaths: options.watchFilePaths ?? [],\n browser: options.browser,\n htmlViteConfig: options.htmlViteConfig,\n scriptViteConfig: options.scriptViteConfig,\n transformManifest: options.transformManifest,\n webExtConfig: options.webExtConfig,\n bundleInfoJsonPath: options.bundleInfoJsonPath,\n onBundleReady: options.onBundleReady,\n verbose: process.argv.includes(\"-d\") || process.argv.includes(\"--debug\"),\n disableColors:\n process.env.CI === \"true\" || process.env.DISABLE_COLORS === \"true\", // TODO: document env var\n };\n\n return manifestLoaderPlugin(internalOptions);\n}\n\n/**\n * Helper function for `JSON.parse(fs.readFileSync(..., \"utf-8\"))`.\n */\nexport function readJsonFile(file: string): any {\n return fs.readJsonSync(file);\n}\n","import * as vite from \"vite\";\nimport type * as rollup from \"rollup\";\nimport { ResolvedOptions, Manifest, ProjectPaths } from \"../options\";\nimport { createLogger } from \"../logger\";\nimport { MANIFEST_LOADER_PLUGIN_NAME } from \"../constants\";\nimport { BuildMode } from \"../build/BuildMode\";\nimport { createBuildContext } from \"../build/build-context\";\nimport {\n defineNoRollupInput,\n resolveBrowserTagsInObject,\n getOutDir,\n getPublicDir,\n getRootDir,\n colorizeFilename,\n} from \"../utils\";\nimport path from \"node:path\";\nimport fs from \"fs-extra\";\nimport { inspect } from \"node:util\";\nimport { createWebExtRunner, ExtensionRunner } from \"../extension-runner\";\nimport { createManifestValidator } from \"../manifest-validation\";\nimport { ContentSecurityPolicy } from \"../csp\";\nimport { renderManifest } from \"../build/renderManifest\";\n\n/**\n * This plugin composes multiple Vite builds together into a single Vite build by calling the\n * `Vite.build` JS API inside the original build.\n *\n * The plugin itself configures just the manifest to be transformed and it starts the \"build\n * context\", where the rest of the build is performed.\n */\nexport function manifestLoaderPlugin(options: ResolvedOptions): vite.Plugin {\n const noInput = defineNoRollupInput();\n const logger = createLogger(options.verbose, options.disableColors);\n const ctx = createBuildContext({ logger, pluginOptions: options });\n const validateManifest = createManifestValidator({ logger });\n\n let mode = BuildMode.BUILD;\n let userConfig: vite.UserConfig;\n let resolvedConfig: vite.ResolvedConfig;\n let extensionRunner: ExtensionRunner;\n let paths: ProjectPaths;\n let isError = false;\n\n /**\n * Set the build mode based on how vite was ran/configured.\n */\n function configureBuildMode(config: vite.UserConfig, env: vite.ConfigEnv) {\n if (env.command === \"serve\") {\n logger.verbose(\"Dev mode\");\n mode = BuildMode.DEV;\n } else if (config.build?.watch) {\n logger.verbose(\"Watch mode\");\n mode = BuildMode.WATCH;\n } else {\n logger.verbose(\"Build mode\");\n mode = BuildMode.BUILD;\n }\n }\n\n /**\n * Loads the manifest.json with it's browser template tags resolved, and the real source file\n * extensions\n */\n async function loadManifest(): Promise<Manifest> {\n let manifestTemplate: Manifest;\n if (typeof options.manifest === \"function\") {\n logger.verbose(\"Loading manifest from function\");\n manifestTemplate = await options.manifest();\n } else {\n // Manifest string should be a path relative to the config.root\n const manifestPath = path.resolve(paths.rootDir, options.manifest);\n logger.verbose(\n `Loading manifest from file @ ${manifestPath} (root: ${paths.rootDir})`\n );\n manifestTemplate = await fs.readJson(manifestPath);\n }\n logger.verbose(\n \"Manifest template: \" + inspect(manifestTemplate, undefined, 5)\n );\n\n const resolvedManifest = resolveBrowserTagsInObject(\n options.browser ?? \"chrome\",\n manifestTemplate\n );\n logger.verbose(\"Manifest with entrypoints: \" + inspect(resolvedManifest));\n return resolvedManifest;\n }\n\n let browserOpened = false;\n async function openBrowser() {\n logger.log(\"\\nOpening browser...\");\n extensionRunner = createWebExtRunner({\n pluginOptions: options,\n paths,\n logger,\n });\n await extensionRunner.openBrowser();\n browserOpened = true;\n logger.log(\"Done!\");\n }\n\n async function buildExtension({\n emitFile,\n server,\n }: {\n emitFile: (asset: rollup.EmittedAsset) => void | Promise<void>;\n server?: vite.ViteDevServer;\n }) {\n // Build\n const manifestWithInputs = await loadManifest();\n await ctx.rebuild({\n paths,\n userConfig,\n manifest: manifestWithInputs,\n mode,\n server,\n viteMode: resolvedConfig.mode,\n onSuccess: async () => {\n await extensionRunner?.reload();\n },\n });\n\n // Generate the manifest based on the output files\n const renderedManifest = renderManifest(\n manifestWithInputs,\n ctx.getBundles()\n );\n\n const finalManifest = options.transformManifest\n ? await options.transformManifest(renderedManifest)\n : renderedManifest;\n\n // Add permissions and CSP for the dev server\n if (mode === BuildMode.DEV) {\n applyDevServerCsp(finalManifest);\n }\n\n if (!options.skipManifestValidation) {\n await validateManifest(finalManifest);\n }\n emitFile({\n type: \"asset\",\n source: JSON.stringify(finalManifest),\n fileName: \"manifest.json\",\n name: \"manifest.json\",\n });\n\n if (options.bundleInfoJsonPath) {\n emitFile({\n type: \"asset\",\n source: JSON.stringify(ctx.getBundles()),\n fileName: options.bundleInfoJsonPath,\n });\n }\n\n await copyPublicDirToOutDir({ mode, paths });\n\n // Handle the onBundleReady callback in dev mode here, as writeBundle is not called in dev mode\n if (mode === BuildMode.DEV && options.onBundleReady) {\n logger.verbose(\"Running onBundleReady\");\n await options.onBundleReady();\n }\n\n // In dev mode, open up the browser immediately after the build context is finished with the\n // first build.\n if (mode === BuildMode.DEV && !options.disableAutoLaunch) {\n await openBrowser();\n }\n }\n\n return {\n name: MANIFEST_LOADER_PLUGIN_NAME,\n\n // Runs during: Build, dev, watch\n async config(config, env) {\n if (options.browser != null) {\n logger.verbose(`Building for browser: ${options.browser}`);\n }\n configureBuildMode(config, env);\n userConfig = config;\n\n return vite.mergeConfig(\n {\n server: {\n // Set the server origin so assets contain the entire url in dev mode, not just the\n // absolute path. See #79. This does not effect scripts or links. They are updated\n // manually in the hmr-rewrite-plugin\n origin: \"http://localhost:5173\",\n },\n build: {\n // Since this plugin schedules multiple builds, we can't let any of the builds empty the\n // outDir. Instead, the plugin cleans up the outDir manually in `onBuildStart`\n emptyOutDir: false,\n },\n } as vite.InlineConfig,\n // We only want to output the manifest.json, so we don't need an input.\n noInput.config\n );\n },\n\n // Runs during: Build, dev, watch\n configResolved(config) {\n resolvedConfig = config;\n paths = {\n rootDir: getRootDir(config),\n outDir: getOutDir(config),\n publicDir: getPublicDir(config),\n };\n },\n\n configureServer(server) {\n server.httpServer?.on(\"listening\", () => {\n // In dev mode, the files have to be built AFTER the server is started so the HTML files can\n // be SSR-ed so they have the correct contents.\n if (mode === BuildMode.DEV) {\n buildExtension({\n server,\n async emitFile(asset) {\n await fs.writeFile(\n path.resolve(paths.outDir, asset.fileName ?? \"unknown\"),\n asset.source ?? \"{}\",\n \"utf8\"\n );\n logger.log(\n \"\\n\\x1b[32m✓\\x1b[0m Wrote \\x1b[95mmanifest.json\\x1b[0m\"\n );\n },\n });\n }\n });\n },\n\n // Runs during: Build, dev, watch\n async buildStart() {\n // Empty out directory\n if (userConfig.build?.emptyOutDir) {\n logger.verbose(\"Removing build.outDir...\");\n await fs.rm(getOutDir(resolvedConfig), {\n recursive: true,\n force: true,\n });\n }\n\n // Add watch files that trigger a full rebuild\n options.watchFilePaths.forEach((file) => this.addWatchFile(file));\n if (typeof options.manifest === \"string\") {\n this.addWatchFile(path.resolve(paths.rootDir, options.manifest));\n }\n\n // This is where we build the extension in build and watch mode.\n if (mode !== BuildMode.DEV) {\n await buildExtension({\n emitFile: (asset) => void this.emitFile(asset),\n });\n }\n },\n\n // Runs during: build, dev, watch\n resolveId(id) {\n return noInput.resolveId(id);\n },\n\n // Runs during: build, dev, watch\n load(id) {\n return noInput.load(id);\n },\n\n // Runs during: build, watch\n buildEnd(err) {\n isError = err != null;\n },\n\n // Runs during: build, watch, dev (only when pressing ctrl+C to stop server)\n async closeBundle() {\n if (isError || mode === BuildMode.BUILD || options.disableAutoLaunch) {\n return;\n }\n\n // Vite4 handles SIGINT (ctrl+C) in dev mode and calls closeBundle after stopping the server.\n // So we need to manually close the open browser.\n if (mode === BuildMode.DEV) return await extensionRunner.exit();\n\n // This is where we open the browser in watch mode.\n await openBrowser();\n },\n\n // Runs during: build, watch\n generateBundle(_, bundle) {\n noInput.cleanupBundle(bundle);\n },\n\n // Runs during: build, watch\n async writeBundle() {\n if (options.onBundleReady) {\n logger.verbose(\"Running onBundleReady\");\n await options.onBundleReady();\n }\n },\n\n // Runs during: watch, dev\n async watchChange(id) {\n if (\n // Only run this hook for `vite build --watch`, not `vite dev`\n mode === BuildMode.DEV ||\n // Don't reload if the browser isn't opened yet\n !browserOpened ||\n // Don't reload if the change was a file written to the output directory\n id.startsWith(paths.outDir)\n )\n return;\n\n const relativePath = path.relative(paths.rootDir, id);\n logger.log(\n `\\n${colorizeFilename(relativePath)} changed, restarting browser`\n );\n await extensionRunner?.exit();\n },\n };\n}\n\n/**\n * Manually copy the public directory at the start of the build during dev/watch mode - vite does\n * this for us in build mode.\n */\nasync function copyPublicDirToOutDir({\n mode,\n paths,\n}: {\n mode: BuildMode;\n paths: ProjectPaths;\n}) {\n if (\n mode === BuildMode.BUILD ||\n !paths.publicDir ||\n !(await fs.pathExists(paths.publicDir))\n ) {\n return;\n }\n\n await fs.copy(paths.publicDir, paths.outDir);\n}\n\nasync function applyDevServerCsp(manifest: Manifest) {\n // TODO: Only add if permission isn't already present\n if (manifest.manifest_version === 3) {\n manifest.host_permissions ??= [];\n manifest.host_permissions.push(\"http://localhost/*\");\n } else {\n manifest.permissions ??= [];\n manifest.permissions.push(\"http://localhost/*\");\n }\n\n const csp = new ContentSecurityPolicy(\n manifest.manifest_version === 3\n ? manifest.content_security_policy?.extension_pages ??\n \"script-src 'self' 'wasm-unsafe-eval'; object-src 'self';\" // default CSP for MV3\n : manifest.content_security_policy ??\n \"script-src 'self'; object-src 'self';\" // default CSP for MV2\n );\n csp.add(\"script-src\", \"http://localhost:*\");\n\n if (manifest.manifest_version === 3) {\n manifest.content_security_policy ??= {};\n manifest.content_security_policy.extension_pages = csp.toString();\n } else {\n manifest.content_security_policy = csp.toString();\n }\n}\n","export const MANIFEST_LOADER_PLUGIN_NAME = `web-extension:manifest`;\nexport const LABELED_STEP_PLUGIN_NAME = `web-extension:labeled-step`;\nexport const MULTIBUILD_COMPLETE_PLUGIN_NAME = `web-extension:multibuild`;\nexport const BUNDLE_TRACKER_PLUGIN_NAME = `web-extension:bundle-tracker`;\nexport const HMR_REWRITE_PLUGIN_NAME = `web-extension:hmr-rewrite`;\n","import { MANIFEST_LOADER_PLUGIN_NAME } from \"./constants\";\n\nexport interface Logger {\n verbose(message: string): void;\n log(message: string): void;\n warn(message: string): void;\n error(message: string, error: unknown): void;\n}\n\nexport let RESET = \"\\x1b[0m\";\nexport let BOLD = \"\\x1b[1m\";\nexport let DIM = \"\\x1b[2m\";\nexport let RED = \"\\x1b[91m\";\nexport let GREEN = \"\\x1b[92m\";\nexport let YELLOW = \"\\x1b[93m\";\nexport let BLUE = \"\\x1b[94m\";\nexport let VIOLET = \"\\x1b[95m\";\nexport let CYAN = \"\\x1b[96m\";\n\nexport function createLogger(\n verbose?: boolean,\n disableColor?: boolean\n): Logger {\n if (disableColor) {\n RESET = \"\";\n BOLD = \"\";\n DIM = \"\";\n RED = \"\";\n GREEN = \"\";\n YELLOW = \"\";\n BLUE = \"\";\n VIOLET = \"\";\n CYAN = \"\";\n }\n return {\n verbose(message: string) {\n if (!verbose) return;\n console.debug(\n message\n .split(\"\\n\")\n .map(\n (line) =>\n ` ${BOLD}${DIM}${MANIFEST_LOADER_PLUGIN_NAME}${RESET} ${line}`\n )\n .join(\"\\n\")\n );\n },\n log(message: string) {\n console.log(message);\n },\n warn(message: string) {\n console.warn(\n message\n .split(\"\\n\")\n .map(\n (line) =>\n `${BOLD}${YELLOW}[${MANIFEST_LOADER_PLUGIN_NAME}] WARN: ${line}${RESET}`\n )\n .join(\"\\n\")\n );\n },\n error(message: string, err: unknown) {\n console.error(\n message\n .split(\"\\n\")\n .map(\n (line) =>\n `${BOLD}${RED}[${MANIFEST_LOADER_PLUGIN_NAME}] ERROR: ${line}${RESET}`\n )\n .join(\"\\n\")\n );\n console.error(err);\n },\n };\n}\n","import * as rollup from \"rollup\";\nimport { inspect } from \"util\";\nimport * as vite from \"vite\";\nimport { ProjectPaths, ResolvedOptions } from \"../options\";\nimport { labeledStepPlugin } from \"../plugins/labeled-step-plugin\";\nimport { BuildMode } from \"./BuildMode\";\nimport { colorizeFilename, getInputPaths } from \"../utils\";\nimport { BOLD, DIM, Logger, RESET, GREEN } from \"../logger\";\nimport { createMultibuildCompleteManager } from \"../plugins/multibuild-complete-plugin\";\nimport { bundleTrackerPlugin } from \"../plugins/bundle-tracker-plugin\";\nimport { getViteConfigsForInputs } from \"./getViteConfigsForInputs\";\nimport { BundleMap } from \"./renderManifest\";\n\ninterface RebuildOptions {\n paths: ProjectPaths;\n userConfig: vite.UserConfig;\n manifest: any;\n mode: BuildMode;\n server?: vite.ViteDevServer;\n onSuccess?: () => Promise<void> | void;\n viteMode: string;\n}\n\nexport interface BuildContext {\n /**\n * Based on the user config and new manifest, rebuild all the entrypoints and update the bundle\n * map.\n */\n rebuild(rebuildOptions: RebuildOptions): Promise<void>;\n getBundles(): BundleMap;\n}\n\n/**\n * Keeps track of everything that needs to be build for the extension, and orchastraits the actual\n * building of each part of the extension.\n */\nexport function createBuildContext({\n pluginOptions,\n logger,\n}: {\n pluginOptions: ResolvedOptions;\n logger: Logger;\n}): BuildContext {\n /**\n * Tracks an each input path relative to the Vite root, to their output filename and a list of\n * generated assets.\n */\n let bundles: BundleMap = {};\n let activeWatchers: rollup.RollupWatcher[] = [];\n\n function getBuildConfigs({\n paths,\n userConfig,\n manifest,\n server,\n onSuccess,\n mode,\n viteMode,\n }: RebuildOptions): vite.InlineConfig[] {\n const entryConfigs = getViteConfigsForInputs({\n paths,\n manifest,\n mode,\n logger,\n server,\n additionalInputs: pluginOptions.additionalInputs,\n baseHtmlViteConfig: pluginOptions.htmlViteConfig ?? {},\n baseSandboxViteConfig: {},\n baseScriptViteConfig: pluginOptions.scriptViteConfig ?? {},\n baseOtherViteConfig: {},\n viteMode,\n });\n const multibuildManager = createMultibuildCompleteManager(async () => {\n // This prints before the manifest plugin continues in watch mode\n if (mode == BuildMode.WATCH) printCompleted();\n await onSuccess?.();\n });\n const totalEntries = entryConfigs.count;\n\n // Merge the entrypoint config required to build something with some required config for child builds\n return entryConfigs.all.map((config, i) =>\n vite.mergeConfig(config, {\n // Reapply the mode passed into the top level build\n mode: viteMode,\n // We shouldn't clear the screen for these internal builds\n clearScreen: false,\n // Don't empty the outDir, this is handled in the parent build process\n build: { emptyOutDir: false },\n plugins: [\n labeledStepPlugin(logger, totalEntries, i, paths),\n multibuildManager.plugin(),\n ],\n })\n );\n }\n\n function printSummary(\n paths: ProjectPaths,\n buildConfigs: vite.InlineConfig[]\n ): void {\n if (buildConfigs.length === 0) return;\n\n const lines = [\"\", `${BOLD}Build Steps${RESET}`];\n buildConfigs.forEach((config, i) => {\n const input = config.build?.rollupOptions?.input ?? config.build?.lib;\n if (!input) return;\n\n const inputs = getInputPaths(paths, input);\n if (inputs.length === 1) {\n lines.push(\n ` ${i + 1}. Building ${colorizeFilename(inputs[0])} indvidually`\n );\n } else {\n lines.push(\n ` ${i + 1}. Bundling ${inputs.length} entrypoints together:`\n );\n inputs.forEach((relativePath) =>\n lines.push(` ${DIM}•${RESET} ${colorizeFilename(relativePath)}`)\n );\n }\n });\n\n logger.log(lines.join(\"\\n\"));\n }\n\n function printCompleted() {\n logger.log(`\\n${GREEN}✓${RESET} All steps completed.\\n`);\n }\n\n function waitForWatchBuildComplete(watcher: rollup.RollupWatcher) {\n return new Promise<void>((res, rej) => {\n watcher.on(\"event\", async (e) => {\n switch (e.code) {\n case \"END\":\n res();\n break;\n case \"ERROR\":\n rej(e.error);\n break;\n }\n });\n });\n }\n\n return {\n async rebuild(rebuildOptions) {\n const { paths, mode } = rebuildOptions;\n await Promise.all(activeWatchers.map((watcher) => watcher.close()));\n activeWatchers = [];\n\n const buildConfigs = getBuildConfigs(rebuildOptions);\n if (pluginOptions.printSummary) printSummary(paths, buildConfigs);\n\n // Print configs deep enough to include rollup inputs\n logger.verbose(\"Final configs: \" + inspect(buildConfigs, undefined, 7));\n\n process.env.VITE_PLUGIN_WEB_EXTENSION_CHILD_BUILD = \"true\";\n try {\n for (const config of buildConfigs) {\n const bundleTracker = bundleTrackerPlugin();\n (config.plugins ??= []).push(bundleTracker);\n\n const output = await vite.build(config);\n if (\"on\" in output) {\n activeWatchers.push(output);\n // In watch mode, wait until it's built once\n await waitForWatchBuildComplete(output);\n }\n\n // Save the bundle chunks\n const input = config.build?.lib ?? config.build?.rollupOptions?.input;\n if (input) {\n const chunks = bundleTracker.getChunks() ?? [];\n for (const file of getInputPaths(paths, input)) {\n bundles[file] = chunks;\n }\n }\n }\n // This prints before the manifest plugin continues in build mode\n if (mode === BuildMode.BUILD) {\n printCompleted();\n }\n } finally {\n // Allow the parent build to rerun its config file (i.e. for a dev server restart)\n process.env.VITE_PLUGIN_WEB_EXTENSION_CHILD_BUILD = \"\";\n }\n },\n getBundles() {\n return bundles;\n },\n };\n}\n","import { GREEN, RESET, CYAN, VIOLET } from \"./logger\";\nimport path from \"node:path\";\nimport * as rollup from \"rollup\";\nimport * as vite from \"vite\";\nimport { ProjectPaths } from \"./options\";\n\n/**\n * Returns the same array, but with null or undefined values removed.\n */\nexport function compact<T>(array: Array<T | undefined>): T[] {\n return array.filter((item) => item != null) as T[];\n}\n\n/**\n * Returns the file path minus the `.[ext]` if present.\n */\nexport function trimExtension(filename: string): string;\nexport function trimExtension(filename: undefined): undefined;\nexport function trimExtension(filename: string | undefined): string | undefined;\nexport function trimExtension(\n filename: string | undefined\n): string | undefined {\n return filename?.replace(path.extname(filename), \"\");\n}\n\n/**\n * Color a filename based on Vite's bundle summary\n * - HTML green\n * - Assets violet\n * - Chunks cyan\n *\n * It's not a perfect match because sometimes JS files are assets, but it's good enough.\n */\nexport function colorizeFilename(filename: string) {\n let color = CYAN;\n if (filename.match(/\\.(html|pug)$/)) color = GREEN;\n if (\n filename.match(/\\.(css|scss|stylus|sass|png|jpg|jpeg|webp|webm|svg|ico)$/)\n )\n color = VIOLET;\n return `${color}${filename}${RESET}`;\n}\n\n/**\n * This generates a set of utils to allow configuring rollup to not use any inputs. It works by\n * adding a virtual, empty JS file as an import, and removing it from the bundle output when\n * finished.\n */\nexport function defineNoRollupInput() {\n const tempId = \"virtual:temp.js\";\n const tempResolvedId = \"\\0\" + tempId;\n const tempContent = \"export const temp = true;\";\n\n return {\n /**\n * Config used to ensure no inputs are required.\n */\n config: <vite.UserConfig>{\n build: {\n lib: {\n entry: tempId,\n formats: [\"es\"], // ES is the most minimal format. Since this is excluded from the bundle, this doesn't matter\n name: tempId,\n fileName: tempId,\n },\n },\n },\n /**\n * Handle resolving the temp entry id.\n */\n resolveId(id: string) {\n if (id.includes(tempId)) return tempResolvedId;\n },\n /**\n * Handle loading a non-empty, basic JS script for the temp input\n */\n load(id: string) {\n if (id === tempResolvedId) return tempContent;\n },\n /**\n * Remove the temporary input from the final bundle.\n */\n cleanupBundle(bundle: rollup.OutputBundle) {\n const tempAsset =\n Object.entries(bundle).find(\n ([_, asset]) =>\n asset.type === \"chunk\" && asset.facadeModuleId === tempResolvedId\n ) ?? [];\n if (tempAsset?.[0] && bundle[tempAsset[0]]) delete bundle[tempAsset[0]];\n },\n };\n}\n\n// TODO: Test\nexport function getRootDir(config: vite.ResolvedConfig): string {\n const cwd = process.cwd();\n const configFileDir = config.configFile\n ? path.resolve(cwd, config.configFile)\n : cwd;\n return path.resolve(configFileDir, config.root);\n}\n\n/**\n * Returns the absolute path to the outDir based on the resolved Vite config.\n *\n * TODO: Test\n *\n * > Must be absolute or it doesn't work on Windows:\n * > https://github.com/aklinker1/vite-plugin-web-extension/issues/63\n */\nexport function getOutDir(config: vite.ResolvedConfig): string {\n const { outDir } = config.build;\n return path.resolve(getRootDir(config), outDir);\n}\n\n// TODO: Test\nexport function getPublicDir(config: vite.ResolvedConfig): string | undefined {\n if (config.publicDir === \"\") return;\n return path.resolve(getRootDir(config), config.publicDir ?? \"public\");\n}\n\n/**\n * Return all the input file paths relative to vite's root. They must be relative paths with unix\n * separators because they must match vite's bundle output paths. Otherwise the manifest will fail\n * to render because a path is different than expected (see #74).\n */\nexport function getInputPaths(\n paths: ProjectPaths,\n input: rollup.InputOption | vite.LibraryOptions\n): string[] {\n let inputs: string[];\n if (typeof input === \"string\") inputs = [input];\n else if (Array.isArray(input)) inputs = input;\n else if (\"entry\" in input)\n inputs = getInputPaths(paths, (input as vite.LibraryOptions).entry);\n else inputs = Object.values(input);\n\n return inputs.map((file) => {\n if (path.isAbsolute(file))\n return path.relative(paths.rootDir, file).replaceAll(\"\\\\\", \"/\");\n return file.replaceAll(\"\\\\\", \"/\");\n });\n}\n\n/**\n * Resolves fields with the `{{browser}}.xyz` prefix on an object. Used to resolve the manifest's\n * fields.\n *\n * @param browser Specify which fields should be used. If `firefox` is passed, it will only keep `{{firefox}}.xyz` values.\n * @param object The object who's fields need resolved. Can be a string, object, or array.\n * @returns The object, but will all it's deeply nested fields that begin with `{{..}}.` resolved.\n */\nexport function resolveBrowserTagsInObject(\n browser: string | undefined,\n object: any\n): any {\n if (Array.isArray(object)) {\n return object\n .map((item) => resolveBrowserTagsInObject(browser, item))\n .filter((item) => !!item);\n } else if (typeof object === \"object\") {\n return Object.keys(object).reduce((newObject, key) => {\n if (!key.startsWith(\"{{\") || key.startsWith(`{{${browser}}}.`)) {\n // @ts-expect-error: bad key typing\n newObject[key.replace(`{{${browser}}}.`, \"\")] =\n resolveBrowserTagsInObject(browser, object[key]);\n }\n return newObject;\n }, {});\n } else if (typeof object === \"string\") {\n if (!object.startsWith(\"{{\") || object.startsWith(`{{${browser}}}.`)) {\n return object.replace(`{{${browser}}}.`, \"\");\n }\n return undefined;\n } else {\n return object;\n }\n}\n\nexport function withTimeout<T>(\n promise: Promise<T>,\n duration: number\n): Promise<T> {\n return new Promise((res, rej) => {\n const timeout = setTimeout(() => {\n rej(`Promise timed out after ${duration}ms`);\n }, duration);\n promise\n .then(res)\n .catch(rej)\n .finally(() => clearTimeout(timeout));\n });\n}\n\n/**\n * Given any kind of entry file (name or path), return the file (name or path) vite will output\n */\nexport function getOutputFile(file: string): string {\n return file\n .replace(/\\.(pug)$/, \".html\")\n .replace(/\\.(scss|stylus|sass)$/, \".css\")\n .replace(/\\.(jsx|ts|tsx)$/, \".js\");\n}\n","import * as vite from \"vite\";\nimport { LABELED_STEP_PLUGIN_NAME } from \"../constants\";\nimport { Logger } from \"../logger\";\nimport { getInputPaths, colorizeFilename } from \"../utils\";\nimport { ProjectPaths } from \"../options\";\n\n/**\n * This plugin is in charge of logging all the steps (but not the summary).\n */\nexport function labeledStepPlugin(\n logger: Logger,\n total: number,\n index: number,\n paths: ProjectPaths\n): vite.Plugin {\n let finalConfig: vite.ResolvedConfig;\n let buildCount = 0;\n\n function printFirstBuild() {\n logger.log(\"\");\n\n const progressLabel = `(${index + 1}/${total})`;\n const input =\n finalConfig.build?.rollupOptions?.input || finalConfig.build.lib;\n if (!input) {\n logger.warn(`Building unknown config ${progressLabel}`);\n return;\n }\n\n const inputs = getInputPaths(paths, input);\n logger.log(\n `Building ${inputs.map(colorizeFilename).join(\", \")} ${progressLabel}`\n );\n }\n\n function printRebuilds() {\n const input = finalConfig.build?.rollupOptions?.input;\n if (input == null) {\n logger.warn(\"Rebuilding unknown config\");\n return;\n }\n\n const files = getInputPaths(paths, input);\n logger.log(`Rebuilding ${files.map(colorizeFilename).join(\", \")}`);\n }\n\n return {\n name: LABELED_STEP_PLUGIN_NAME,\n configResolved(config) {\n finalConfig = config;\n if (buildCount == 0) printFirstBuild();\n else printRebuilds();\n\n buildCount++;\n },\n };\n}\n","import * as vite from \"vite\";\nimport { MULTIBUILD_COMPLETE_PLUGIN_NAME } from \"../constants\";\nimport Lock from \"async-lock\";\n\n/**\n * Generate plugins that track how many builds are in progress at a single time, and their statuses\n * (error or success). After no more builds are running, and all builds have a success status,\n * the `onBuildsSucceeded` callback will be invoked.\n */\nexport function createMultibuildCompleteManager(\n onBuildsSucceeded: () => Promise<void> | void\n) {\n let activeBuilds = 0;\n const buildStatuses: { [buildId: number]: Error } = {};\n let nextBuildId = 0;\n let hasTriggeredCallback = false;\n\n const lock = new Lock();\n const lockKey = \"builds\";\n\n function incrementBuildCount(buildId: number) {\n return lock.acquire(lockKey, () => {\n activeBuilds++;\n hasTriggeredCallback = false;\n delete buildStatuses[buildId];\n });\n }\n function decreaseBuildCount(buildId: number, err: Error | undefined) {\n return lock.acquire(lockKey, async () => {\n activeBuilds--;\n if (err == null) delete buildStatuses[buildId];\n else buildStatuses[buildId] = err;\n });\n }\n /**\n * Make sure the builds are completed and there are no errors, then call the callback.\n */\n function checkCompleted() {\n return lock.acquire(lockKey, async () => {\n if (\n activeBuilds === 0 &&\n Object.values(buildStatuses).length === 0 &&\n !hasTriggeredCallback\n ) {\n hasTriggeredCallback = true;\n await onBuildsSucceeded();\n }\n });\n }\n\n return {\n plugin(): vite.Plugin {\n const buildId = nextBuildId++;\n // Increment initially because we know there is a build queued up\n incrementBuildCount(buildId);\n let hasBuildOnce = false;\n return {\n name: MULTIBUILD_COMPLETE_PLUGIN_NAME,\n enforce: \"post\",\n async buildStart() {\n // Skip incrementing the first time since we already did it when the plugin was created\n if (hasBuildOnce) await incrementBuildCount(buildId);\n hasBuildOnce = true;\n },\n /**\n * This hook is called regardless of if the build threw an error, so it's the only reliable\n * place that can decrement the build counter regardless of build success.\n */\n async buildEnd(err) {\n await decreaseBuildCount(buildId, err);\n },\n /**\n * Call the completed callback AFTER the bundle has closed, so output files have been\n * written to the disk.\n *\n * This is only called on success. Only when the SLOWEST build finishes on success. So we\n * still need to check to make sure all builds have finished and were successful. We also\n * only want to cal the callback from one of the plugin instances, not all of them. So we\n * only call the callback from the first plugin instance that finished.\n */\n async closeBundle() {\n await checkCompleted();\n },\n };\n },\n };\n}\n","import * as vite from \"vite\";\nimport { BUNDLE_TRACKER_PLUGIN_NAME } from \"../constants\";\n\nexport interface BundleTrackerPlugin extends vite.Plugin {\n getChunks(): string[] | undefined;\n}\n\n/**\n * A plugin that tracks and saves the output bundle.\n *\n * When rendering the final manifest, we need to add any files the inputs generated, and the chunks\n * return by this plugin are used to get the generated files.\n */\nexport function bundleTrackerPlugin(): BundleTrackerPlugin {\n let chunks: string[] | undefined;\n return {\n name: BUNDLE_TRACKER_PLUGIN_NAME,\n buildStart() {\n chunks = undefined;\n },\n writeBundle(_, bundle) {\n chunks = Object.values(bundle).map((chunk) => chunk.fileName);\n },\n getChunks: () => chunks,\n };\n}\n","import path from \"node:path\";\nimport * as vite from \"vite\";\nimport type browser from \"webextension-polyfill\";\nimport { Logger } from \"../logger\";\nimport { ProjectPaths, Manifest } from \"../options\";\nimport { hmrRewritePlugin } from \"../plugins/hmr-rewrite-plugin\";\nimport { compact, trimExtension } from \"../utils\";\nimport { BuildMode } from \"./BuildMode\";\n\nconst HTML_ENTRY_REGEX = /\\.(html)$/;\nconst SCRIPT_ENTRY_REGEX = /\\.(js|ts|mjs|mts)$/;\nconst CSS_ENTRY_REGEX = /\\.(css|scss|sass|less|stylus)$/;\n\nclass CombinedViteConfigs {\n /**\n * A single config that builds all the HTML pages.\n */\n html?: vite.InlineConfig;\n /**\n * A single config that builds all the HTML pages for sandbox. These are separate from `html`\n * because we want to properly tree-shake out any browser API usages, since those APIs aren't\n * available in sandbox pages.\n */\n sandbox?: vite.InlineConfig;\n /**\n * All other JS inputs from the manifest and additional inputs are separated into their own\n * configs.\n *\n * Unlike tsup, Vite cannot be given multiple entry-points, and produce individual bundles for\n * each entrypoint. Vite can only produce code-split outputs that share other files, which\n * extensions cannot consume. So we build each of these separately.\n */\n scripts?: vite.InlineConfig[];\n /**\n * CSS files cannot be built with vite 5 as the input to lib mode.\n */\n css?: vite.InlineConfig[];\n /**\n * Similar to scripts, but for other file \"types\". Sometimes CSS, SCSS, JSON, images, etc, can be\n * passed into Vite directly. The most common case of this in extensions are CSS files listed for\n * content scripts.\n */\n other?: vite.InlineConfig[];\n\n /**\n * The total number of configs required to build the extension.\n */\n get count(): number {\n return this.all.length;\n }\n\n /**\n * Return all the configs as an array.\n */\n get all(): vite.InlineConfig[] {\n return compact(\n [this.html, this.sandbox, this.scripts, this.css, this.other].flat()\n );\n }\n\n applyBaseConfig(baseConfig: vite.InlineConfig) {\n if (this.html) this.html = vite.mergeConfig(baseConfig, this.html);\n if (this.sandbox) this.sandbox = vite.mergeConfig(baseConfig, this.sandbox);\n this.scripts = this.scripts?.map((config) =>\n vite.mergeConfig(baseConfig, config)\n );\n this.css = this.css?.map((config) => vite.mergeConfig(baseConfig, config));\n this.other = this.other?.map((config) =>\n vite.mergeConfig(baseConfig, config)\n );\n }\n}\n\n/**\n * Given an input `manifest.json` with source code paths and `options.additionalInputs`, return a\n * set of Vite configs that can be used to build all the entry-points for the extension.\n */\nexport function getViteConfigsForInputs(options: {\n paths: ProjectPaths;\n mode: BuildMode;\n additionalInputs: string[];\n manifest: Manifest;\n logger: Logger;\n server?: vite.ViteDevServer;\n baseHtmlViteConfig: vite.InlineConfig;\n baseSandboxViteConfig: vite.InlineConfig;\n baseScriptViteConfig: vite.InlineConfig;\n baseOtherViteConfig: vite.InlineConfig;\n viteMode: string;\n}): CombinedViteConfigs {\n const { paths, additionalInputs, manifest, mode, logger, server } = options;\n const configs = new CombinedViteConfigs();\n\n const processedInputs = new Set<string>();\n const hasBeenProcessed = (input: string) => processedInputs.has(input);\n\n /**\n * For a list of entry-points, build them all in multi-page mode:\n * <https://vitejs.dev/guide/build.html#multi-page-app>\n */\n function getMultiPageConfig(\n entries: string[],\n baseConfig: vite.InlineConfig\n ): vite.InlineConfig | undefined {\n const newEntries = entries.filter((entry) => !hasBeenProcessed(entry));\n newEntries.forEach((entry) => processedInputs.add(entry));\n\n if (newEntries.length === 0) return;\n\n const plugins =\n mode === BuildMode.DEV\n ? [\n hmrRewritePlugin({\n server: server!,\n paths,\n logger,\n }),\n ]\n : [];\n\n const inputConfig: vite.InlineConfig = {\n plugins,\n build: {\n rollupOptions: {\n input: newEntries.reduce<Record<string, string>>((input, entry) => {\n input[trimExtension(entry)] = path.resolve(paths.rootDir, entry);\n return input;\n }, {}),\n output: {\n // Configure the output filenames so they appear in the same folder\n // - popup/index.html\n // - popup/index.js\n entryFileNames: `[name].js`,\n chunkFileNames: `[name].js`,\n /**\n * [name] for assetFileNames is either the filename or whole path. So if you\n * have two `index.html` files in different directories, they would overwrite each\n * other as `dist/index.css`.\n *\n * See [#47](https://github.com/aklinker1/vite-plugin-web-extension/issues/47) for\n * more details.\n */\n assetFileNames: ({ name }) => {\n if (!name) return \"[name].[ext]\";\n\n if (name && path.isAbsolute(name)) {\n name = path.relative(paths.rootDir, name);\n }\n return `${trimExtension(name)}.[ext]`;\n },\n },\n },\n },\n };\n return vite.mergeConfig(baseConfig, inputConfig);\n }\n\n /**\n * For a given entry-point, get the vite config use to bundle it into a single file.\n */\n function getIndividualConfig(\n entry: string,\n baseConfig: vite.InlineConfig\n ): vite.InlineConfig | undefined {\n if (hasBeenProcessed(entry)) return;\n processedInputs.add(entry);\n\n /**\n * \"content-scripts/some-script/index\" -> \"content-scripts/some-script/\"\n * \"some-script\" -> \"\"\n */\n const moduleId = trimExtension(entry);\n const inputConfig: vite.InlineConfig = {\n build: {\n watch: mode !== BuildMode.BUILD ? {} : undefined,\n lib: {\n name: \"_\",\n entry,\n formats: [\"iife\"],\n fileName: () => moduleId + \".js\",\n },\n },\n define: {\n // See https://github.com/aklinker1/vite-plugin-web-extension/issues/96\n \"process.env.NODE_ENV\": `\"${options.viteMode}\"`,\n },\n };\n return vite.mergeConfig(baseConfig, inputConfig);\n }\n\n function getHtmlConfig(entries: string[]): vite.InlineConfig | undefined {\n return getMultiPageConfig(entries, options.baseHtmlViteConfig);\n }\n function getSandboxConfig(entries: string[]): vite.InlineConfig | undefined {\n return getMultiPageConfig(entries, options.baseSandboxViteConfig);\n }\n function getScriptConfig(entry: string): vite.InlineConfig | undefined {\n return getIndividualConfig(entry, options.baseScriptViteConfig);\n }\n function getOtherConfig(entry: string): vite.InlineConfig | undefined {\n return getIndividualConfig(entry, options.baseOtherViteConfig);\n }\n function getCssConfig(entry: string): vite.InlineConfig | undefined {\n return getMultiPageConfig([entry], options.baseOtherViteConfig);\n }\n\n const {\n htmlAdditionalInputs,\n otherAdditionalInputs,\n scriptAdditionalInputs,\n cssAdditionalInputs,\n } = separateAdditionalInputs(additionalInputs);\n\n // HTML Pages\n const htmlEntries = simplifyEntriesList([\n manifest.action?.default_popup,\n manifest.devtools_page,\n manifest.options_page,\n manifest.options_ui?.page,\n manifest.browser_action?.default_popup,\n manifest.page_action?.default_popup,\n manifest.side_panel?.default_path,\n manifest.sidebar_action?.default_panel,\n manifest.background?.page,\n manifest.chrome_url_overrides?.bookmarks,\n manifest.chrome_url_overrides?.history,\n manifest.chrome_url_overrides?.newtab,\n manifest.chrome_settings_overrides?.homepage,\n htmlAdditionalInputs,\n ]);\n const sandboxEntries = simplifyEntriesList([manifest.sandbox?.pages]);\n\n configs.html = getHtmlConfig(htmlEntries);\n configs.sandbox = getSandboxConfig(sandboxEntries);\n\n // Scripts\n compact(\n simplifyEntriesList([\n manifest.background?.service_worker,\n manifest.background?.scripts,\n manifest.content_scripts?.flatMap(\n (cs: browser.Manifest.ContentScript) => cs.js\n ),\n scriptAdditionalInputs,\n ]).map(getScriptConfig)\n ).forEach((scriptConfig) => {\n configs.scripts ??= [];\n configs.scripts.push(scriptConfig);\n });\n\n // CSS\n compact(\n simplifyEntriesList([\n manifest.content_scripts?.flatMap(\n (cs: browser.Manifest.ContentScript) => cs.css\n ),\n cssAdditionalInputs,\n ]).map(getCssConfig)\n ).forEach((cssConfig) => {\n configs.css ??= [];\n configs.css.push(cssConfig);\n });\n\n // Other Types\n compact(\n simplifyEntriesList([otherAdditionalInputs]).map(getOtherConfig)\n ).forEach((otherConfig) => {\n configs.other ??= [];\n configs.other.push(otherConfig);\n });\n\n validateCombinedViteConfigs(configs);\n return configs;\n}\n\n/**\n * `options.additionalInputs` accepts html files, scripts, CSS, and other file entry-points. This\n * method breaks those apart into their related groups (html, script, CSS, other).\n */\nfunction separateAdditionalInputs(additionalInputs: string[]) {\n const scriptAdditionalInputs: string[] = [];\n const otherAdditionalInputs: string[] = [];\n const htmlAdditionalInputs: string[] = [];\n const cssAdditionalInputs: string[] = [];\n\n additionalInputs?.forEach((input) => {\n if (HTML_ENTRY_REGEX.test(input)) htmlAdditionalInputs.push(input);\n else if (SCRIPT_ENTRY_REGEX.test(input)) scriptAdditionalInputs.push(input);\n else if (CSS_ENTRY_REGEX.test(input)) cssAdditionalInputs.push(input);\n else scriptAdditionalInputs.push(input);\n });\n\n return {\n scriptAdditionalInputs,\n otherAdditionalInputs,\n htmlAdditionalInputs,\n cssAdditionalInputs,\n };\n}\n\n/**\n * Take in a list of any combination of single entries, lists of entries, or undefined and return a\n * single, simple list of all the truthy, non-public, entry-points\n */\nfunction simplifyEntriesList(\n a: Array<string | string[] | undefined> | undefined\n): string[] {\n return compact<string>(a?.flat() ?? []).filter(\n (a) => !a.startsWith(\"public:\")\n );\n}\n\nfunction validateCombinedViteConfigs(configs: CombinedViteConfigs) {\n if (configs.count === 0) {\n throw Error(\n \"No inputs found in manifest.json. Run Vite with `--debug` for more details.\"\n );\n }\n}\n","/**\n * This plugin is responsible for rewriting the entry HTML files to point towards the dev server.\n */\nimport * as vite from \"vite\";\nimport { HMR_REWRITE_PLUGIN_NAME } from \"../constants\";\nimport { parseHTML } from \"linkedom\";\nimport path from \"path\";\nimport { ProjectPaths } from \"../options\";\nimport { Logger } from \"../logger\";\nimport { inspect } from \"util\";\n\nexport function hmrRewritePlugin(config: {\n server: vite.ViteDevServer;\n paths: ProjectPaths;\n logger: Logger;\n}): vite.Plugin {\n const { paths, logger, server } = config;\n let inputIds: string[] = [];\n\n const serverOptions = server.config.server;\n let hmrOptions =\n typeof server.config.server.hmr === \"object\"\n ? server.config.server.hmr\n : undefined;\n\n // Coped from node_modules/vite, do a global search for: vite:client-inject\n function serializeDefine(define: Record<string, any>): string {\n let res = `{`;\n const keys = Object.keys(define);\n for (let i = 0; i < keys.length; i++) {\n const key = keys[i];\n const val = define[key];\n res += `${JSON.stringify(key)}: ${handleDefineValue(val)}`;\n if (i !== keys.length - 1) {\n res += `, `;\n }\n }\n return res + `}`;\n }\n\n function handleDefineValue(value: any): string {\n if (typeof value === \"undefined\") return \"undefined\";\n if (typeof value === \"string\") return value;\n return JSON.stringify(value);\n }\n\n return {\n name: HMR_REWRITE_PLUGIN_NAME,\n\n config(config) {\n inputIds = Object.values(config.build?.rollupOptions?.input ?? {}).map(\n (inputId) => vite.normalizePath(inputId)\n );\n\n return {\n server: {\n hmr: {\n protocol: \"http:\",\n host: \"localhost\",\n port: 5173,\n },\n },\n define: {\n // Coped from node_modules/vite, do a global search for: vite:client-inject\n // These are used in node_modules/vite/dist/client/client.mjs, check there to see if a var\n // can be null or not.\n __MODE__: JSON.stringify(config.mode || null),\n __BASE__: JSON.stringify(serverOptions.base || \"/\"),\n __DEFINES__: serializeDefine(config.define || {}),\n __SERVER_HOST__: JSON.stringify(serverOptions.host || \"localhost\"),\n __HMR_PROTOCOL__: JSON.stringify(hmrOptions?.protocol || null),\n __HMR_HOSTNAME__: JSON.stringify(hmrOptions?.host || \"localhost\"),\n __HMR_PORT__: JSON.stringify(\n hmrOptions?.clientPort || hmrOptions?.port || 5173\n ),\n __HMR_DIRECT_TARGET__: JSON.stringify(\n `${serverOptions.host ?? \"localhost\"}:${\n serverOptions.port ?? 5173\n }${config.base || \"/\"}`\n ),\n __HMR_BASE__: JSON.stringify(serverOptions.base ?? \"/\"),\n __HMR_TIMEOUT__: JSON.stringify(hmrOptions?.timeout || 30000),\n __HMR_ENABLE_OVERLAY__: JSON.stringify(hmrOptions?.overlay !== false),\n },\n };\n },\n async transform(code, id) {\n // Only transform HTML inputs\n if (!id.endsWith(\".html\") || !inputIds.includes(id)) return;\n\n const baseUrl = \"http://localhost:5173\";\n\n // Load scripts from dev server, this adds the /@vite/client script to the page\n const serverCode = await server.transformIndexHtml(id, code);\n\n const { document } = parseHTML(serverCode);\n const pointToDevServer = (querySelector: string, attr: string): void => {\n document.querySelectorAll(querySelector).forEach((element) => {\n const src = element.getAttribute(attr);\n if (!src) return;\n\n const before = element.outerHTML;\n\n if (path.isAbsolute(src)) {\n element.setAttribute(attr, baseUrl + src);\n } else if (src.startsWith(\".\")) {\n const abs = path.resolve(path.dirname(id), src);\n const pathname = path.relative(paths.rootDir, abs);\n element.setAttribute(attr, `${baseUrl}/${pathname}`);\n }\n\n const after = element.outerHTML;\n if (before !== after) {\n logger.verbose(\n \"Transformed for dev mode: \" + inspect({ before, after })\n );\n }\n });\n };\n\n pointToDevServer(\"script[type=module]\", \"src\");\n pointToDevServer(\"link[rel=stylesheet]\", \"href\");\n\n // Return new HTML\n return document.toString();\n },\n };\n}\n","import { ProjectPaths, ResolvedOptions } from \"../options\";\nimport { Logger } from \"../logger\";\nimport { ExtensionRunner } from \"./interface\";\nimport * as webExtLogger from \"web-