wxt
Version:
⚡ Next-gen Web Extension Framework
361 lines (360 loc) • 14 kB
JavaScript
import { normalizePath } from "../../utils/paths.mjs";
import { getEntrypointBundlePath, isHtmlEntrypoint } from "../../utils/entrypoints.mjs";
import "../../utils/index.mjs";
import { toArray } from "../../utils/arrays.mjs";
import { createExtensionEnvironment } from "../../utils/environments/extension-environment.mjs";
import "../../utils/environments/index.mjs";
import { safeVarName } from "../../utils/strings.mjs";
import { devHtmlPrerender } from "./plugins/devHtmlPrerender.mjs";
import { devServerGlobals } from "./plugins/devServerGlobals.mjs";
import { resolveVirtualModules } from "./plugins/resolveVirtualModules.mjs";
import { tsconfigPaths } from "./plugins/tsconfigPaths.mjs";
import { noopBackground } from "./plugins/noopBackground.mjs";
import { cssEntrypoints } from "./plugins/cssEntrypoints.mjs";
import { bundleAnalysis } from "./plugins/bundleAnalysis.mjs";
import { globals } from "./plugins/globals.mjs";
import { entrypointGroupGlobals } from "./plugins/entrypointGroupGlobals.mjs";
import { defineImportMeta } from "./plugins/defineImportMeta.mjs";
import { removeEntrypointMainFunction } from "./plugins/removeEntrypointMainFunction.mjs";
import { wxtPluginLoader } from "./plugins/wxtPluginLoader.mjs";
import { resolveAppConfig } from "./plugins/resolveAppConfig.mjs";
import { iifeFooter } from "./plugins/iifeFooter.mjs";
import { iifeAnonymous } from "./plugins/iifeAnonymous.mjs";
import "./plugins/index.mjs";
import { mkdir, readdir, rename, rmdir, stat } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path";
//#region src/core/builders/vite/index.ts
async function createViteBuilder(wxtConfig, hooks, getWxtDevServer) {
const vite = await import("vite");
/**
* Returns the base vite config shared by all builds based on the inline and
* user config.
*/
const getBaseConfig = async (baseConfigOptions) => {
const config = await wxtConfig.vite(wxtConfig.env);
config.root = wxtConfig.root;
config.configFile = false;
config.logLevel = "warn";
config.mode = wxtConfig.mode;
config.envPrefix ??= ["VITE_", "WXT_"];
config.build ??= {};
config.publicDir = wxtConfig.publicDir;
config.build.copyPublicDir = false;
config.build.outDir = wxtConfig.outDir;
config.build.emptyOutDir = false;
if (config.build.minify == null && wxtConfig.command === "serve") config.build.minify = false;
if (config.build.sourcemap == null && wxtConfig.command === "serve") config.build.sourcemap = "inline";
config.server ??= {};
config.server.watch = {
...wxtConfig.watchOptions,
ignored: [
`${wxtConfig.outBaseDir}/**`,
`${wxtConfig.wxtDir}/**`,
...getRunnerProfileWatchIgnores(wxtConfig),
...toArray(wxtConfig.watchOptions.ignored ?? [])
]
};
config.legacy ??= {};
config.legacy.skipWebSocketTokenCheck = true;
if (isRolldownVersion(vite.version)) {} else {
config.esbuild ??= {};
if (config.esbuild) config.esbuild.charset = "ascii";
}
const server = getWxtDevServer?.();
config.plugins ??= [];
config.plugins.push(devHtmlPrerender(wxtConfig, server), resolveVirtualModules(wxtConfig), devServerGlobals(wxtConfig, server), tsconfigPaths(wxtConfig), noopBackground(), globals(wxtConfig), defineImportMeta(), wxtPluginLoader(wxtConfig), resolveAppConfig(wxtConfig));
if (wxtConfig.analysis.enabled && !baseConfigOptions?.excludeAnalysisPlugin) config.plugins.push(bundleAnalysis(wxtConfig));
return config;
};
/**
* Return the basic config for building an entrypoint in [lib
* mode](https://vitejs.dev/guide/build.html#library-mode).
*/
const getLibModeConfig = (entrypoint) => {
const entry = getRollupEntry(entrypoint);
const plugins = [entrypointGroupGlobals(entrypoint)];
let iifeReturnValueName = safeVarName(entrypoint.name);
if (entrypoint.type === "content-script-style" || entrypoint.type === "unlisted-style") plugins.push(cssEntrypoints(entrypoint, wxtConfig));
if (entrypoint.type === "content-script" || entrypoint.type === "unlisted-script") {
if (typeof entrypoint.options.globalName === "string") iifeReturnValueName = entrypoint.options.globalName;
else if (typeof entrypoint.options.globalName === "function") iifeReturnValueName = entrypoint.options.globalName(entrypoint);
if (!entrypoint.options.globalName) plugins.push(iifeAnonymous(iifeReturnValueName));
else plugins.push(iifeFooter(iifeReturnValueName));
}
return {
mode: wxtConfig.mode,
plugins,
build: {
lib: {
entry,
formats: ["iife"],
name: iifeReturnValueName,
fileName: entrypoint.name
},
rollupOptions: { output: {
entryFileNames: getEntrypointBundlePath(entrypoint, wxtConfig.outDir, ".js"),
assetFileNames: (assetInfo) => {
if (entrypoint.type === "content-script" && getRollupAssetNames(assetInfo).some((name) => name.endsWith("css"))) return `content-scripts/${entrypoint.name}.[ext]`;
else return `assets/${entrypoint.name}.[ext]`;
}
} }
},
define: { "process.env.NODE_ENV": JSON.stringify(wxtConfig.mode) }
};
};
/**
* Return the basic config for building multiple entrypoints in [multi-page
* mode](https://vitejs.dev/guide/build.html#multi-page-app).
*/
const getMultiPageConfig = (entrypoints) => {
const htmlEntrypoints = new Set(entrypoints.filter(isHtmlEntrypoint).map((e) => e.name));
return {
mode: wxtConfig.mode,
plugins: [entrypointGroupGlobals(entrypoints)],
build: { rollupOptions: {
input: entrypoints.reduce((input, entry) => {
input[entry.name] = getRollupEntry(entry);
return input;
}, {}),
output: {
chunkFileNames: "chunks/[name]-[hash].js",
entryFileNames: ({ name }) => {
if (htmlEntrypoints.has(name)) return "chunks/[name]-[hash].js";
return "[name].js";
},
assetFileNames: "assets/[name]-[hash].[ext]"
}
} }
};
};
/**
* Return the basic config for building a single CSS entrypoint in [multi-page
* mode](https://vitejs.dev/guide/build.html#multi-page-app).
*/
const getCssConfig = (entrypoint) => {
return {
mode: wxtConfig.mode,
plugins: [entrypointGroupGlobals(entrypoint)],
build: { rollupOptions: {
input: { [entrypoint.name]: entrypoint.inputPath },
output: { assetFileNames: () => {
if (entrypoint.type === "content-script-style") return `content-scripts/${entrypoint.name}.[ext]`;
else return `assets/${entrypoint.name}.[ext]`;
} }
} }
};
};
const createImporterEnvironment = async (paths) => {
const baseConfig = await getBaseConfig({ excludeAnalysisPlugin: true });
baseConfig.optimizeDeps ??= {};
baseConfig.optimizeDeps.noDiscovery = true;
baseConfig.optimizeDeps.include = [];
const envConfig = { plugins: paths.map((path) => removeEntrypointMainFunction(wxtConfig, path)) };
const importerConfig = vite.mergeConfig(baseConfig, envConfig);
const config = await vite.resolveConfig(vite.mergeConfig(importerConfig || {}, {
configFile: false,
envDir: false,
cacheDir: process.cwd(),
environments: { inline: {
consumer: "server",
dev: { moduleRunnerTransform: true },
resolve: {
external: true,
mainFields: [],
conditions: ["node"]
}
} }
}), "serve");
const environment = vite.createRunnableDevEnvironment("inline", config, {
runnerOptions: { hmr: { logger: false } },
hot: false
});
await environment.init();
return environment;
};
function requireDefaultExport(path, mod) {
const relativePath = relative(wxtConfig.root, path);
if (mod?.default == null) {
const defineFn = relativePath.includes(".content") ? "defineContentScript" : relativePath.includes("background") ? "defineBackground" : "defineUnlistedScript";
throw Error(`${relativePath}: Default export not found, did you forget to call "export default ${defineFn}(...)"?`);
}
}
return {
name: "Vite",
version: vite.version,
async importEntrypoint(path) {
const [module] = await this.importEntrypoints([path]);
return module;
},
async importEntrypoints(paths) {
const context = createExtensionEnvironment();
const environment = await createImporterEnvironment(paths);
try {
return await context.run(async () => await Promise.all(paths.map(async (path) => {
const module = await environment.runner.import(path);
requireDefaultExport(path, module);
return module.default;
})));
} finally {
await environment.close();
}
},
async build(group) {
let entryConfig;
if (Array.isArray(group)) entryConfig = getMultiPageConfig(group);
else if (group.type === "content-script-style" || group.type === "unlisted-style") entryConfig = getCssConfig(group);
else entryConfig = getLibModeConfig(group);
const buildConfig = vite.mergeConfig(await getBaseConfig(), entryConfig);
await hooks.callHook("vite:build:extendConfig", toArray(group), buildConfig);
return {
entrypoints: group,
chunks: await moveHtmlFiles(wxtConfig, group, getBuildOutputChunks(await vite.build(buildConfig)))
};
},
async createServer(info) {
const serverConfig = { server: {
host: info.host,
port: info.port,
strictPort: true,
origin: info.origin
} };
const baseConfig = await getBaseConfig();
const finalConfig = vite.mergeConfig(baseConfig, serverConfig);
await hooks.callHook("vite:devServer:extendConfig", finalConfig);
const viteServer = await vite.createServer(finalConfig);
return {
async listen() {
await viteServer.listen(info.port);
},
async close() {
await viteServer.close();
},
transformHtml(...args) {
return viteServer.transformIndexHtml(...args);
},
ws: {
send(message, payload) {
return viteServer.ws.send(message, payload);
},
on(message, cb) {
viteServer.ws.on(message, cb);
}
},
watcher: viteServer.watcher,
on(event, cb) {
viteServer.httpServer?.on(event, cb);
}
};
}
};
}
function getRunnerProfileWatchIgnores(wxtConfig) {
const root = normalizePath(wxtConfig.root);
const chromiumArgProfiles = extractPathArgs(wxtConfig.webExt.config?.chromiumArgs, "--user-data-dir");
const firefoxArgProfiles = extractPathArgs(wxtConfig.webExt.config?.firefoxArgs, "-profile");
const profiles = [
wxtConfig.webExt.config?.chromiumProfile,
wxtConfig.webExt.config?.firefoxProfile,
...chromiumArgProfiles,
...firefoxArgProfiles
].filter((profile) => typeof profile === "string");
return Array.from(new Set(profiles.map((profile) => normalizePath(resolve(wxtConfig.root, profile))).filter((profilePath) => profilePath !== root).map((profilePath) => `${profilePath}/**`)));
}
function extractPathArgs(args, flag) {
if (!args?.length) return [];
const paths = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith(`${flag}=`)) {
const value = arg.slice(flag.length + 1).trim();
if (value) paths.push(value);
continue;
}
if (arg === flag) {
const nextValue = args[i + 1]?.trim();
if (nextValue) paths.push(nextValue);
i += 1;
}
}
return paths;
}
function getRollupAssetNames(assetInfo) {
if (Array.isArray(assetInfo.names)) return assetInfo.names;
return assetInfo.name ? [assetInfo.name] : [];
}
function getBuildOutputChunks(result) {
if ("on" in result) throw Error("wxt does not support vite watch mode.");
if (Array.isArray(result)) return result.flatMap(({ output }) => output);
return result.output;
}
/**
* Returns the input module ID (virtual or real file) for an entrypoint. The
* returned string should be passed as an input to rollup.
*/
function getRollupEntry(entrypoint) {
let virtualEntrypointType;
switch (entrypoint.type) {
case "background":
case "unlisted-script":
virtualEntrypointType = entrypoint.type;
break;
case "content-script":
virtualEntrypointType = entrypoint.options.world === "MAIN" ? "content-script-main-world" : "content-script-isolated-world";
break;
}
if (virtualEntrypointType) return `${`virtual:wxt-${virtualEntrypointType}-entrypoint`}?${entrypoint.inputPath}`;
return entrypoint.inputPath;
}
/**
* Ensures the HTML files output by a multi-page build are in the correct
* location. This does two things:
*
* 1. Moves the HTML files to their final location at
* `<outDir>/<entrypoint.name>.html`.
* 2. Updates the bundle so it summarizes the files correctly in the returned build
* output.
*
* Assets (JS and CSS) are output to the `<outDir>/assets` directory, and don't
* need to be modified. HTML files access them via absolute URLs, so we don't
* need to update any import paths in the HTML files either.
*/
async function moveHtmlFiles(config, group, chunks) {
if (!Array.isArray(group)) return chunks;
const entryMap = group.reduce((map, entry) => {
const a = normalizePath(relative(config.root, entry.inputPath));
map[a] = entry;
return map;
}, {});
const movedChunks = await Promise.all(chunks.map(async (chunk) => {
if (!chunk.fileName.endsWith(".html")) return chunk;
const entry = entryMap[chunk.fileName];
const oldBundlePath = chunk.fileName;
const newBundlePath = getEntrypointBundlePath(entry, config.outDir, extname(chunk.fileName));
const oldAbsPath = join(config.outDir, oldBundlePath);
const newAbsPath = join(config.outDir, newBundlePath);
await mkdir(dirname(newAbsPath), { recursive: true });
await rename(oldAbsPath, newAbsPath);
return {
...chunk,
fileName: newBundlePath
};
}));
await removeEmptyDirs(config.outDir);
return movedChunks;
}
/** Recursively remove all directories that are empty/ */
async function removeEmptyDirs(dir) {
const files = await readdir(dir);
for (const file of files) {
const filePath = join(dir, file);
if ((await stat(filePath)).isDirectory()) await removeEmptyDirs(filePath);
}
try {
await rmdir(dir);
} catch {}
}
function isRolldownVersion(version) {
return Number(version.split(".")[0]) >= 8;
}
//#endregion
export { createViteBuilder };