@crxjs/vite-plugin
Version:
Build Chrome Extensions with this Vite plugin.
1,250 lines (1,227 loc) • 118 kB
JavaScript
import { simple } from 'acorn-walk';
import { createHash, randomBytes } from 'crypto';
import debug$5 from 'debug';
import { join, normalize, dirname, basename, isAbsolute, relative, resolve, parse as parse$1 } from 'pathe';
import { Subject, filter, ReplaySubject, switchMap, of, startWith, tap, debounceTime, map, share, BehaviorSubject, mergeMap, firstValueFrom, takeUntil, first, toArray, retry, concatWith, Subscription, buffer } from 'rxjs';
import fsx from 'fs-extra';
import { performance } from 'perf_hooks';
import { rollup } from 'rollup';
import * as lexer from 'es-module-lexer';
import { readFile as readFile$1 } from 'fs/promises';
import MagicString from 'magic-string';
import { build, mergeConfig, createLogger, version } from 'vite';
import convertSourceMap from 'convert-source-map';
import pc from 'picocolors';
import { readFileSync, existsSync, promises } from 'fs';
import { createRequire } from 'module';
import { glob, isDynamicPattern } from 'tinyglobby';
import { parse } from 'node-html-parser';
import jsesc from 'jsesc';
const pluginName$1 = "crx:optionsProvider";
const pluginOptionsProvider = (options) => {
return {
name: pluginName$1,
api: {
crx: {
// during testing this can be null, we don't provide options through the test config
options
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
};
};
const getOptions = async ({
plugins
}) => {
if (typeof plugins === "undefined") {
throw new Error("config.plugins is undefined");
}
const awaitedPlugins = await Promise.all(plugins);
let options;
for (const p of awaitedPlugins.flat()) {
if (isCrxPlugin(p)) {
if (p.name === pluginName$1) {
const plugin = p;
options = plugin.api.crx.options;
if (options)
break;
}
}
}
if (typeof options === "undefined") {
throw Error("Unable to get CRXJS options");
}
return options;
};
function isCrxPlugin(p) {
return !!p && typeof p === "object" && !(p instanceof Promise) && !Array.isArray(p) && p.name.startsWith("crx:");
}
var workerHmrClient = "const crxClientPortName = `@crx/client:${__CRX_HMR_TOKEN__}`;\nconst ownOrigin = `chrome-extension://${chrome.runtime.id}`;\nself.addEventListener(\"fetch\", (fetchEvent) => {\n const url = new URL(fetchEvent.request.url);\n if (url.origin === ownOrigin) {\n fetchEvent.respondWith(sendToServer(fetchEvent.request));\n }\n});\nasync function sendToServer(req) {\n const url = new URL(req.url);\n const requestHeaders = new Headers(req.headers);\n url.protocol = __SERVER_PROTO__ + \":\";\n url.host = \"localhost\";\n url.port = __SERVER_PORT__;\n url.searchParams.set(\"t\", Date.now().toString());\n const response = await fetch(url.href.replace(/=$|=(?=&)/g, \"\"), {\n headers: requestHeaders\n });\n const responseHeaders = new Headers(response.headers);\n responseHeaders.set(\n \"Content-Type\",\n responseHeaders.get(\"Content-Type\") ?? \"text/javascript\"\n );\n responseHeaders.set(\n \"Cache-Control\",\n responseHeaders.get(\"Cache-Control\") ?? \"\"\n );\n return new Response(response.body, {\n headers: responseHeaders\n });\n}\nconst ports = /* @__PURE__ */ new Set();\nfunction isExternalSenderAllowed(port) {\n return !port.sender?.id || port.sender.id === chrome.runtime.id;\n}\nfunction handlePort(port, { external = false } = {}) {\n if (port.name === crxClientPortName && (!external || isExternalSenderAllowed(port))) {\n ports.add(port);\n port.onDisconnect.addListener((port2) => {\n if (chrome.runtime.lastError) {\n console.error(chrome.runtime.lastError);\n }\n ports.delete(port2);\n });\n port.onMessage.addListener((message) => {\n });\n port.postMessage({ data: JSON.stringify({ type: \"connected\" }) });\n }\n}\nchrome.runtime.onConnect.addListener(handlePort);\nchrome.runtime.onConnectExternal.addListener(\n (port) => handlePort(port, { external: true })\n);\nfunction notifyContentScripts(payload) {\n const data = JSON.stringify(payload);\n for (const port of ports)\n port.postMessage({ data });\n}\nconsole.log(\"[vite] connecting...\");\nconst socketProtocol = __HMR_PROTOCOL__ || (location.protocol === \"https:\" ? \"wss\" : \"ws\");\nconst socketToken = __HMR_TOKEN__;\nconst socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`;\nconst socket = new WebSocket(\n `${socketProtocol}://${socketHost}?token=${socketToken}`,\n \"vite-hmr\"\n);\nconst base = __BASE__ || \"/\";\nsocket.addEventListener(\"message\", async ({ data }) => {\n handleSocketMessage(JSON.parse(data));\n});\nfunction isCrxHmrPayload(x) {\n return x.type === \"custom\" && x.event.startsWith(\"crx:\");\n}\nfunction handleSocketMessage(payload) {\n if (isCrxHmrPayload(payload)) {\n handleCrxHmrPayload(payload);\n } else if (payload.type === \"connected\") {\n console.log(`[vite] connected.`);\n const interval = setInterval(() => socket.send(\"ping\"), __HMR_TIMEOUT__);\n socket.addEventListener(\"close\", () => clearInterval(interval));\n }\n}\nfunction handleCrxHmrPayload(payload) {\n if (!__LIVE_RELOAD__) {\n if (payload.event === \"crx:runtime-reload\") {\n console.log(\"[crx] runtime reload suppressed (liveReload disabled)\");\n }\n return;\n }\n notifyContentScripts(payload);\n switch (payload.event) {\n case \"crx:runtime-reload\":\n console.log(\"[crx] runtime reload\");\n chrome.runtime.reload();\n break;\n }\n}\nasync function waitForSuccessfulPing(ms = 1e3) {\n while (true) {\n try {\n await fetch(`${base}__vite_ping`);\n break;\n } catch (e) {\n await new Promise((resolve) => setTimeout(resolve, ms));\n }\n }\n}\nsocket.addEventListener(\"close\", async ({ wasClean }) => {\n if (wasClean)\n return;\n console.log(`[vite] server connection lost. polling for restart...`);\n await waitForSuccessfulPing();\n if (__LIVE_RELOAD__) {\n handleCrxHmrPayload({\n type: \"custom\",\n event: \"crx:runtime-reload\"\n });\n } else {\n console.log(\n \"[crx] server reconnected, skipping reload (liveReload disabled)\"\n );\n }\n});\n";
const _debug = (id) => debug$5("crx").extend(id);
const hash = (data, length = 5) => createHash("sha1").update(data).digest("base64").replace(/[^A-Za-z0-9]/g, "").slice(0, length);
const isString = (x) => typeof x === "string";
function isObject(value) {
return Object.prototype.toString.call(value) === "[object Object]";
}
const isResourceByMatch = (x) => "matches" in x;
function decodeManifest(code) {
const tree = this.parse(code);
let literal;
let templateElement;
simple(tree, {
Literal(node) {
literal = node;
},
TemplateElement(node) {
templateElement = node;
}
});
let manifestJson = literal?.value;
if (!manifestJson)
manifestJson = templateElement?.value?.cooked;
if (!manifestJson)
throw new Error("unable to parse manifest code");
let result = JSON.parse(manifestJson);
if (typeof result === "string")
result = JSON.parse(result);
return result;
}
function encodeManifest(manifest) {
const json = JSON.stringify(JSON.stringify(manifest));
return `export default ${json}`;
}
function parseJsonAsset(bundle, key) {
const asset = bundle[key];
if (typeof asset === "undefined")
throw new TypeError(`OutputBundle["${key}"] is undefined.`);
if (asset.type !== "asset")
throw new Error(`OutputBundle["${key}"] is not an OutputAsset.`);
if (typeof asset.source !== "string")
throw new TypeError(`OutputBundle["${key}"].source is not a string.`);
return JSON.parse(asset.source);
}
const getMatchPatternOrigin = (pattern) => {
if (pattern.startsWith("<"))
return pattern;
const [schema, rest] = pattern.split("://");
const slashIndex = rest.indexOf("/");
const isSlashAfterOriginPresent = slashIndex !== -1;
const origin = isSlashAfterOriginPresent ? rest.slice(0, slashIndex) : rest;
const root = `${schema}://${origin}`;
if (isSlashAfterOriginPresent) {
return `${root}/*`;
}
return root;
};
function defineClientValues(code, config) {
let options = config.server.hmr;
options = options && typeof options !== "boolean" ? options : {};
const host = options.host || null;
const protocol = options.protocol || null;
const timeout = options.timeout || 3e4;
const overlay = options.overlay !== false;
let hmrPort;
if (isObject(config.server.hmr)) {
hmrPort = config.server.hmr.clientPort || config.server.hmr.port;
}
if (config.server.middlewareMode) {
hmrPort = String(hmrPort || 24678);
} else {
hmrPort = String(hmrPort || options.port || config.server.port);
}
let hmrBase = config.base;
if (options.path) {
hmrBase = join(hmrBase, options.path);
}
if (hmrBase !== "/") {
hmrPort = normalize(`${hmrPort}${hmrBase}`);
}
return code.replace(`__MODE__`, JSON.stringify(config.mode)).replace(`__BASE__`, JSON.stringify(config.base)).replace(`__DEFINES__`, serializeDefine(config.define || {})).replace(`__HMR_TOKEN__`, JSON.stringify(config.webSocketToken || "")).replace(`__HMR_PROTOCOL__`, JSON.stringify(protocol)).replace(`__HMR_HOSTNAME__`, JSON.stringify(host)).replace(`__HMR_PORT__`, JSON.stringify(hmrPort)).replace(`__HMR_TIMEOUT__`, JSON.stringify(timeout)).replace(`__HMR_ENABLE_OVERLAY__`, JSON.stringify(overlay)).replace(
`__SERVER_PROTO__`,
JSON.stringify(config.server.https ? "https" : "http")
).replace(
`__SERVER_PORT__`,
JSON.stringify(config.server.port?.toString())
);
function serializeDefine(define) {
let res = `{`;
for (const key in define) {
const val = define[key];
res += `${JSON.stringify(key)}: ${typeof val === "string" ? `(${val})` : JSON.stringify(val)}, `;
}
return res + `}`;
}
}
class RxMap extends Map {
static isChangeType = {
clear: (x) => x.type === "clear",
delete: (x) => x.type === "delete",
set: (x) => x.type === "set"
};
change$;
constructor(iterable) {
super(iterable);
const change$ = new Subject();
this.change$ = change$.asObservable();
const changeMethodKeys = ["clear", "set", "delete"];
for (const type of changeMethodKeys) {
const method = this[type];
this[type] = function(...args) {
const result = method.call(this, ...args);
change$.next({ type, key: args[0], value: args[1], map: this });
return result;
}.bind(this);
}
}
}
const outputFiles = new RxMap();
_debug("file-writer").extend("utilities");
function sanitizeUnderscorePrefix(fileName) {
const dir = dirname(fileName);
let base = basename(fileName);
while (base.startsWith("_")) {
base = base.slice(1);
}
if (!base) {
base = "file";
}
return dir === "." ? base : join(dir, base);
}
function prefix$1(prefix2, text) {
return text.startsWith(prefix2) ? text : prefix2 + text;
}
function strip(prefix2, text) {
return text?.startsWith(prefix2) ? text?.slice(prefix2.length) : text;
}
function formatFileData(script) {
script.id = prefix$1("/", script.id);
if (script.fileName)
script.fileName = strip("/", script.fileName);
if (script.loaderName)
script.loaderName = strip("/", script.loaderName);
return script;
}
function getFileName({ type, id }) {
let fileName = id.replace(/t=\d+&/, "").replace(/\?t=\d+$/, "").replace(/^\//, "").replace(/\?/g, "__").replace(/&/g, "_").replace(/=/g, "--").replace(/:/g, "-");
if (fileName.includes("node_modules/")) {
fileName = `vendor/${fileName.split("node_modules/").pop().replace(/\//g, "-")}`;
} else if (fileName.startsWith("@")) {
fileName = `vendor/${fileName.slice("@".length).replace(/\//g, "-")}`;
} else if (fileName.startsWith(".vite/deps/")) {
fileName = `vendor/${fileName.slice(".vite/deps/".length)}`;
}
fileName = sanitizeUnderscorePrefix(fileName);
switch (type) {
case "iife":
return `${fileName}.iife.js`;
case "loader":
return `${fileName}-loader.js`;
case "module":
return `${fileName}.js`;
case "asset":
return fileName;
default:
throw new Error(
`Unexpected script type "${type}" for "${JSON.stringify({
type,
id
})}"`
);
}
}
function getOutputPath(server, fileName) {
const {
root,
build: { outDir }
} = server.config;
const target = isAbsolute(outDir) ? join(outDir, fileName) : join(root, outDir, fileName);
return target;
}
function getViteUrl({ type, id }, { timestamp = false } = {}) {
if (timestamp && !id.startsWith("/@") && !id.includes("?v=")) {
const t = `t=${Date.now()}` + (id.includes("?") ? "&" : "");
const parts = id.split("?");
parts[1] = typeof parts[1] === "undefined" ? t : t + parts[1];
id = parts.join("?");
}
if (type === "asset") {
throw new Error(`File type "${type}" not implemented.`);
} else if (type === "iife") {
throw new Error(`File type "iife" is handled via dedicated IIFE bundler, not Vite transform.`);
} else if (type === "loader") {
throw new Error("Vite does not transform loader files.");
} else if (type === "module") {
if (id.startsWith("/@id/"))
return id.slice("/@id/".length).replace("__x00__", "\0");
return prefix$1("/", id);
} else {
throw new Error(`Invalid file type: "${type}"`);
}
}
async function fileReady(script) {
const fileName = getFileName(script);
const file = outputFiles.get(fileName);
if (!file)
throw new Error("unknown script type and id");
const { deps } = await file.file;
await Promise.all(deps.map(fileReady));
}
const crxHmrTokens = /* @__PURE__ */ new WeakMap();
function getCrxHmrToken(config) {
if (config.webSocketToken)
return config.webSocketToken;
let token = crxHmrTokens.get(config);
if (!token) {
token = randomBytes(16).toString("hex");
crxHmrTokens.set(config, token);
}
return token;
}
const viteClientId = "/@vite/client";
const customElementsId = "/@webcomponents/custom-elements";
const contentHmrPortId = "/@crx/client-port";
const manifestId = "/@crx/manifest";
const preambleId = "/@crx/client-preamble";
const stubId = "/@crx/stub";
const workerClientId = "/@crx/client-worker";
const contentCssPrefix = "/@crx/content-css/";
function isContentCssId(id) {
return id.startsWith(contentCssPrefix);
}
function getContentCssId(index) {
return `${contentCssPrefix}${index}`;
}
function getContentCssIndex(id) {
if (!isContentCssId(id))
return null;
const indexStr = id.slice(contentCssPrefix.length);
const index = parseInt(indexStr, 10);
return isNaN(index) ? null : index;
}
const pluginBackground = () => {
let config;
let browser;
let liveReload = true;
return [
{
name: "crx:background-client",
apply: "serve",
resolveId(source) {
if (source === `/${workerClientId}`)
return workerClientId;
},
load(id) {
if (id === workerClientId) {
const base = `${config.server.https ? "https" : "http"}://localhost:${config.server.port}/`;
return defineClientValues(
workerHmrClient.replace("__BASE__", JSON.stringify(base)).replace("__LIVE_RELOAD__", JSON.stringify(liveReload)).replace(
"__CRX_HMR_TOKEN__",
JSON.stringify(
getCrxHmrToken(config)
)
),
config
);
}
}
},
{
name: "crx:background-loader-file",
// this should happen after other plugins; the loader file is an implementation detail
enforce: "post",
async config(config2) {
const opts = await getOptions(config2);
browser = opts.browser || "chrome";
liveReload = opts.liveReload !== false;
},
configResolved(_config) {
config = _config;
},
renderCrxManifest(manifest) {
const worker = browser === "firefox" ? manifest.background?.scripts[0] : manifest.background?.service_worker;
let loader;
if (config.command === "serve") {
const proto = config.server.https ? "https" : "http";
const port = config.server.port?.toString();
if (typeof port === "undefined")
throw new Error("server port is undefined in watch mode");
if (browser === "firefox") {
loader = `import('${proto}://localhost:${port}/@vite/env');
`;
loader += `import('${proto}://localhost:${port}${workerClientId}');
`;
if (worker)
loader += `import('${proto}://localhost:${port}/${worker}');
`;
} else {
loader = `import '${proto}://localhost:${port}/@vite/env';
`;
loader += `import '${proto}://localhost:${port}${workerClientId}';
`;
if (worker)
loader += `import '${proto}://localhost:${port}/${worker}';
`;
}
} else if (worker) {
loader = `import './${worker}';
`;
} else {
return null;
}
const refId = this.emitFile({
type: "asset",
// fileName b/c service worker must be at root of crx
fileName: getFileName({ type: "loader", id: "service-worker" }),
source: loader
});
if (browser !== "firefox") {
manifest.background = {
service_worker: this.getFileName(refId),
type: "module"
};
} else {
manifest.background = {
scripts: [this.getFileName(refId)],
type: "module"
};
}
return manifest;
}
}
];
};
const extensionOrigins = [/^chrome-extension:\/\//, /^moz-extension:\/\//];
function isExtensionOrigin(origin) {
return origin ? extensionOrigins.some((pattern) => pattern.test(origin)) : false;
}
function addExtensionOrigins(origin) {
if (origin === true)
return true;
if (typeof origin === "function") {
return (requestOrigin, cb) => {
if (isExtensionOrigin(requestOrigin)) {
cb(null, true);
return;
}
origin(requestOrigin, cb);
};
}
if (Array.isArray(origin))
return [...origin, ...extensionOrigins];
if (origin)
return [origin, ...extensionOrigins];
return extensionOrigins;
}
function addExtensionCors(cors) {
if (cors === true)
return true;
if (cors && typeof cors === "object") {
return {
...cors,
origin: addExtensionOrigins(cors.origin)
};
}
return { origin: extensionOrigins };
}
function pluginExtensionCors() {
return {
name: "crx:extension-cors",
apply: "serve",
config(config) {
config.server = {
...config.server,
cors: addExtensionCors(config.server?.cors)
};
}
};
}
var contentHmrPort = "const crxClientPortName = `@crx/client:${__CRX_HMR_TOKEN__}`;\nfunction hasOwnExtensionRuntime(runtime2, extensionId2) {\n try {\n return new URL(runtime2.getURL(\"\")).host === extensionId2;\n } catch {\n return false;\n }\n}\nconst runtime = typeof chrome === \"undefined\" ? void 0 : chrome.runtime;\nconst extensionId = new URL(import.meta.url).host;\nconst connectsToOwnRuntime = runtime ? hasOwnExtensionRuntime(runtime, extensionId) : false;\nfunction isCrxHMRPayload(x) {\n return x.type === \"custom\" && x.event.startsWith(\"crx:\");\n}\nclass HMRPort {\n port;\n callbacks = /* @__PURE__ */ new Map();\n constructor() {\n setInterval(() => {\n try {\n this.port?.postMessage({ data: \"ping\" });\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"Extension context invalidated.\")) {\n location.reload();\n } else\n throw error;\n }\n }, __CRX_HMR_TIMEOUT__);\n setInterval(this.initPort, 5 * 60 * 1e3);\n this.initPort();\n }\n initPort = () => {\n if (!runtime)\n throw new Error(\"[crx] chrome.runtime is not available\");\n const connectInfo = { name: crxClientPortName };\n this.port?.disconnect();\n this.port = connectsToOwnRuntime ? runtime.connect(connectInfo) : runtime.connect(extensionId, connectInfo);\n this.port.onDisconnect.addListener(this.handleDisconnect.bind(this));\n this.port.onMessage.addListener(this.handleMessage.bind(this));\n this.port.postMessage({ type: \"connected\" });\n };\n handleDisconnect = () => {\n if (this.callbacks.has(\"close\"))\n for (const cb of this.callbacks.get(\"close\")) {\n cb({ wasClean: true });\n }\n };\n handleMessage = (message) => {\n const forward = (data) => {\n if (this.callbacks.has(\"message\"))\n for (const cb of this.callbacks.get(\"message\")) {\n cb({ data });\n }\n };\n const payload = JSON.parse(message.data);\n if (isCrxHMRPayload(payload)) {\n if (payload.event === \"crx:runtime-reload\") {\n if (__CRX_LIVE_RELOAD__) {\n console.log(\"[crx] runtime reload\");\n setTimeout(() => location.reload(), 500);\n } else {\n console.log(\"[crx] runtime reload suppressed (liveReload disabled)\");\n }\n } else {\n forward(JSON.stringify(payload.data));\n }\n } else {\n forward(message.data);\n }\n };\n addEventListener = (event, callback) => {\n const cbs = this.callbacks.get(event) ?? /* @__PURE__ */ new Set();\n cbs.add(callback);\n this.callbacks.set(event, cbs);\n };\n send = (data) => {\n if (this.port)\n this.port.postMessage({ data });\n else\n throw new Error(\"HMRPort is not initialized\");\n };\n}\n\nexport { HMRPort };\n";
var contentDevLoader = "(function () {\n 'use strict';\n\n const injectTime = performance.now();\n (async () => {\n if (__PREAMBLE__)\n await import(\n /* @vite-ignore */\n chrome.runtime.getURL(__PREAMBLE__)\n );\n await import(\n /* @vite-ignore */\n chrome.runtime.getURL(__CLIENT__)\n );\n const { onExecute } = await import(\n /* @vite-ignore */\n chrome.runtime.getURL(__SCRIPT__)\n );\n onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });\n })().catch(console.error);\n\n})();\n";
var contentDevMainLoader = "(function () {\n 'use strict';\n\n const injectTime = performance.now();\n (async () => {\n try {\n if (__PREAMBLE__)\n await import(\n /* @vite-ignore */\n __PREAMBLE__\n );\n await import(\n /* @vite-ignore */\n __CLIENT__\n );\n } catch (error) {\n console.warn(\"[crx] MAIN world HMR client failed to load\", error);\n }\n const { onExecute } = await import(\n /* @vite-ignore */\n __SCRIPT__\n );\n onExecute?.({\n perf: { injectTime, loadTime: performance.now() - injectTime }\n });\n })().catch(console.error);\n\n})();\n";
var contentProLoader = "(function () {\n 'use strict';\n\n const injectTime = performance.now();\n (async () => {\n const { onExecute } = await import(\n /* @vite-ignore */\n chrome.runtime.getURL(__SCRIPT__)\n );\n onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });\n })().catch(console.error);\n\n})();\n";
var contentProMainLoader = "(function () {\n 'use strict';\n\n const injectTime = performance.now();\n (async () => {\n const { onExecute } = await import(\n /* @vite-ignore */\n __SCRIPT__\n );\n onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });\n })().catch(console.error);\n\n})();\n";
const contentScripts = new RxMap();
contentScripts.change$.pipe(filter(RxMap.isChangeType.set)).subscribe(({ map, value }) => {
const keyNames = [
"refId",
"id",
"fileName",
"loaderName",
"resolvedId",
"scriptId"
];
const keys = keyNames.map((keyName) => value[keyName]);
keys.push(value.id.replace(/^\//, ""));
for (const key of keys) {
if (typeof key === "undefined" || map.get(key) === value) {
continue;
} else {
map.set(key, value);
}
}
});
function hashScriptId(script) {
return hash(`${script.type}&${script.id}`);
}
function createDevLoader({
preamble,
client,
fileName
}) {
return contentDevLoader.replace(/__PREAMBLE__/g, JSON.stringify(preamble)).replace(/__CLIENT__/g, JSON.stringify(client)).replace(/__SCRIPT__/g, JSON.stringify(fileName)).replace(/__TIMESTAMP__/g, JSON.stringify(Date.now()));
}
function createProLoader({ fileName }) {
return contentProLoader.replace(/__SCRIPT__/g, JSON.stringify(fileName));
}
function createDevMainLoader({
preamble,
client,
fileName
}) {
return contentDevMainLoader.replace(/__PREAMBLE__/g, JSON.stringify(preamble)).replace(/__CLIENT__/g, JSON.stringify(client)).replace(/__SCRIPT__/g, JSON.stringify(fileName)).replace(/__TIMESTAMP__/g, JSON.stringify(Date.now()));
}
function createProMainLoader({
fileName
}) {
return contentProMainLoader.replace(/__SCRIPT__/g, JSON.stringify(fileName));
}
const { outputFile: outputFile$1 } = fsx;
const getIifeGlobalName = (fileName) => {
const base = fileName.split("/").pop() ?? fileName;
const sanitized = base.replace(/\W+/g, "_").replace(/^_+/, "");
return `crx_${sanitized || "content_script"}`;
};
const resolveScriptInput = (server, id) => {
if (id.startsWith("/@fs/"))
return id.slice("/@fs/".length);
if (id.startsWith("/"))
return join(server.config.root, id.slice(1));
return id;
};
const isOutputChunk = (item) => item.type === "chunk";
const isOutputAsset = (item) => item.type === "asset";
const serverEvent$ = new ReplaySubject(1);
const close$ = serverEvent$.pipe(
filter((e) => e.type === "close"),
switchMap((e) => of(e))
);
const start$ = serverEvent$.pipe(
filter((e) => e.type === "start"),
switchMap((e) => of(e))
);
const fileWriterEvent$ = new ReplaySubject(1);
const buildEnd$ = fileWriterEvent$.pipe(
filter((e) => e.type === "build_end"),
switchMap((e) => of(e))
);
fileWriterEvent$.pipe(
filter((e) => e.type === "build_start"),
switchMap((e) => of(e))
);
const allFilesReadyDebounceMs = 100;
let currentAllFilesReadyGeneration = 0;
let completedAllFilesReadyGeneration = 0;
let lastAllFilesReadyResults;
const allFilesReadyState$ = buildEnd$.pipe(
switchMap(
() => outputFiles.change$.pipe(
startWith({ type: "start" }),
tap(() => {
currentAllFilesReadyGeneration += 1;
}),
debounceTime(allFilesReadyDebounceMs)
)
),
map(() => ({
generation: currentAllFilesReadyGeneration,
files: [...outputFiles.values()]
})),
switchMap(async ({ generation, files }) => {
const seen = /* @__PURE__ */ new Set();
const results = await Promise.allSettled(
files.map((file) => waitForOutputFile(file, seen))
);
return { generation, results };
}),
tap(({ generation, results }) => {
completedAllFilesReadyGeneration = generation;
lastAllFilesReadyResults = results;
}),
share()
);
const allFilesReady$ = allFilesReadyState$.pipe(
map(({ results }) => results)
);
async function waitForOutputFile(file, seen = /* @__PURE__ */ new Set()) {
if (seen.has(file))
return;
seen.add(file);
const { deps } = await file.file;
await Promise.all(deps.map((dep) => waitForOutputFile(dep, seen)));
}
async function waitForAllFilesReadyResults() {
const targetGeneration = currentAllFilesReadyGeneration;
if (lastAllFilesReadyResults && completedAllFilesReadyGeneration >= targetGeneration) {
return lastAllFilesReadyResults;
}
const { results } = await firstValueFrom(
allFilesReadyState$.pipe(
filter(({ generation }) => generation >= targetGeneration)
)
);
return results;
}
const timestamp$ = new BehaviorSubject(Date.now());
allFilesReady$.subscribe(() => {
timestamp$.next(Date.now());
});
const isRejected = (x) => x?.status === "rejected";
const fileWriterError$ = allFilesReady$.pipe(
mergeMap((results) => results.filter(isRejected)),
map((rejected) => ({ err: rejected.reason, type: "error" }))
);
firstValueFrom(
fileWriterError$.pipe(
takeUntil(serverEvent$.pipe(first(({ type }) => type === "close"))),
toArray()
)
);
function prepFileData(fileId) {
const fileName = getFileName(fileId);
if (fileId.type === "asset") {
return prepAsset(fileName, fileId);
} else {
return prepScript(fileName, fileId);
}
}
function prepAsset(fileName, { id, source }) {
return ($) => $.pipe(
mergeMap(async ({ server }) => {
const target = getOutputPath(server, fileName);
return {
target,
source: source ?? await readFile$1(join(server.config.root, id)),
deps: []
};
})
);
}
function prepScript(fileName, script) {
if (script.type === "iife")
return prepIifeScript(fileName, script);
return ($) => $.pipe(
// get script contents from dev server
mergeMap(async ({ server }) => {
const target = getOutputPath(server, fileName);
const originalViteUrl = getViteUrl(script);
const isVueSfcQuery = script.id.includes("?vue");
const viteUrl = getViteUrl(script, { timestamp: isVueSfcQuery });
if (isVueSfcQuery) {
const module = await server.moduleGraph.getModuleByUrl(originalViteUrl);
if (module)
server.moduleGraph.invalidateModule(module);
}
const transformResult = await server.transformRequest(viteUrl);
if (!transformResult)
throw new TypeError(`Unable to load "${script.id}" from server.`);
const { deps = [], dynamicDeps = [], map: map2 } = transformResult;
let { code } = transformResult;
try {
if (map2 && server.config.build.sourcemap === "inline") {
code = code.replace(/\n*\/\/# sourceMappingURL=[^\n]+/g, "");
const sourceMap = convertSourceMap.fromObject(map2).toComment();
code += `
${sourceMap}
`;
}
} catch (error) {
console.warn("Failed to inline source map", error);
}
return {
target,
code,
deps: [...deps, ...dynamicDeps].flat(),
server
};
}),
// retry in case of dependency rebundle
retry({ count: 10, delay: 100 }),
// patch content scripts
mergeMap(async ({ target, server, ...rest }) => {
const plugins = server.config.plugins;
let { code, deps } = rest;
for (const plugin of plugins) {
const r = await plugin.renderCrxDevScript?.(code, script);
if (typeof r === "string")
code = r;
}
return { target, code, deps };
}),
mergeMap(async ({ target, code, deps }) => {
await lexer.init;
const [imports] = lexer.parse(code, fileName);
const isSelfDependency = (id) => getFileName({ type: "module", id }) === fileName;
const depSet = new Set(deps.filter((id) => !isSelfDependency(id)));
const magic = new MagicString(code);
for (const i of imports)
if (i.n) {
const depFileName = getFileName({ type: "module", id: i.n });
if (!isSelfDependency(i.n))
depSet.add(i.n);
const fullImport = code.substring(i.s, i.e);
magic.overwrite(i.s, i.e, fullImport.replace(i.n, `/${depFileName}`));
}
return { target, source: magic.toString(), deps: [...depSet] };
})
);
}
async function bundleIife(server, script, fileName) {
const input = resolveScriptInput(server, script.id);
const sourcemap = server.config.build.sourcemap === "inline" ? "inline" : false;
const result = await build({
root: server.config.root,
mode: server.config.mode,
configFile: false,
// Don't load user's config - use minimal IIFE-specific settings
logLevel: "silent",
resolve: {
// Copy resolve settings from the dev server for consistency
alias: server.config.resolve.alias,
extensions: server.config.resolve.extensions,
conditions: server.config.resolve.conditions
},
build: {
write: false,
// Don't write to disk, we'll handle that
manifest: false,
// Don't generate Vite manifest
rollupOptions: {
input,
output: {
format: "iife",
name: getIifeGlobalName(fileName),
entryFileNames: fileName,
inlineDynamicImports: true,
// Required for IIFE format
sourcemap
}
},
minify: false,
copyPublicDir: false
}
});
const outputs = Array.isArray(result) ? result : [result];
const firstOutput = outputs[0];
const output = "output" in firstOutput ? firstOutput.output : void 0;
if (!output) {
throw new Error(`Unable to generate IIFE bundle for "${script.id}"`);
}
const entryChunk = output.find(
(item) => isOutputChunk(item) && item.isEntry
);
if (!entryChunk) {
throw new Error(`Unable to generate IIFE bundle for "${script.id}"`);
}
const assets = output.filter(isOutputAsset).filter(
// Filter out manifest.json to avoid overwriting extension manifest
(asset) => asset.fileName !== "manifest.json" && !asset.fileName.startsWith(".vite/")
);
const extraChunks = output.filter(
(item) => isOutputChunk(item) && !item.isEntry
);
return {
code: entryChunk.code,
assets,
extraChunks
};
}
function prepIifeScript(fileName, script) {
return ($) => $.pipe(
mergeMap(async ({ server }) => {
const target = getOutputPath(server, fileName);
const { code, assets, extraChunks } = await bundleIife(
server,
script,
fileName
);
return { target, source: code, deps: [], server, assets, extraChunks };
}),
mergeMap(
async ({ target, source, deps, server, assets, extraChunks }) => {
const extras = [
...assets.map((asset) => ({
fileName: asset.fileName,
source: asset.source
})),
...extraChunks.map((chunk) => ({
fileName: chunk.fileName,
source: chunk.code
}))
].filter((item) => item.fileName !== fileName);
await Promise.all(
extras.map(async (item) => {
const outputPath = getOutputPath(server, item.fileName);
if (typeof item.source === "undefined" || item.source === null)
return;
if (item.source instanceof Uint8Array)
await outputFile$1(outputPath, item.source);
else
await outputFile$1(outputPath, item.source, { encoding: "utf8" });
})
);
return { target, source, deps };
}
)
);
}
async function allFilesReady() {
await waitForAllFilesReadyResults();
}
const { outputFile } = fsx;
const debug$4 = _debug("file-writer");
function getRollupInputOptions(options) {
const {
platform: _platform,
resolve: _resolve,
transform: _transform,
moduleTypes: _moduleTypes,
optimization: _optimization,
experimental: _experimental,
cwd: _cwd,
...rollupOptions
} = options;
return rollupOptions;
}
function queueWrite(script, previous) {
if (!previous)
return write(script);
return previous.file.catch(() => void 0).then(() => write(script));
}
async function start({
server
}) {
serverEvent$.next({ type: "start", server });
const plugins = server.config.plugins.filter(
(p) => p.name?.startsWith("crx:")
);
const { rollupOptions, outDir } = server.config.build;
const rollupInputOptions = getRollupInputOptions(rollupOptions);
const inputOptions = {
input: "index.html",
...rollupInputOptions,
plugins
};
const rollupOutputOptions = [rollupOptions.output].flat()[0];
const outputOptions = {
...rollupOutputOptions,
dir: outDir,
format: "es"
};
fileWriterEvent$.next({ type: "build_start" });
const build = await rollup(inputOptions);
await build.write(outputOptions);
fileWriterEvent$.next({ type: "build_end" });
await allFilesReady();
}
async function close() {
serverEvent$.next({ type: "close" });
}
function add(script) {
const fileName = getFileName(script);
debug$4(
"add: script.id=%s script.type=%s fileName=%s",
script.id,
script.type,
fileName
);
let file = outputFiles.get(fileName);
if (typeof file === "undefined") {
file = formatFileData({
...script,
fileName,
file: queueWrite(script)
});
outputFiles.set(file.fileName, file);
debug$4("add: stored new file %s", file.fileName);
} else {
const isVirtualModule = script.id.startsWith("/@id/") || script.id.startsWith("/__");
const isTimestampedModule = script.type === "module" && /[?&]t=\d+/.test(script.id);
if (isVirtualModule || isTimestampedModule) {
debug$4(
"add: module already exists, triggering re-write for %s",
fileName
);
file = formatFileData({
...file,
...script,
fileName,
file: queueWrite(script, file)
});
outputFiles.set(fileName, file);
}
}
return file;
}
function update(_id) {
const id = prefix$1("/", _id);
const types = ["iife", "module"];
const updatedFiles = [];
debug$4("update called: _id=%s id=%s", _id, id);
for (const type of types) {
const fileName = getFileName({ id, type });
debug$4("update: looking for fileName=%s", fileName);
const scriptFile = outputFiles.get(fileName);
if (scriptFile) {
debug$4("update: found file, calling write()");
scriptFile.file = queueWrite({ id, type }, scriptFile);
updatedFiles.push(scriptFile);
outputFiles.set(fileName, scriptFile);
}
}
debug$4("update: returning %d files", updatedFiles.length);
return updatedFiles;
}
async function write(fileId) {
const start2 = performance.now();
const deps = await firstValueFrom(
// wait for start event
start$.pipe(
// prepare either asset or script contents
prepFileData(fileId),
// output file and add dependencies to file writer
mergeMap(async ({ target, source, deps: deps2 }) => {
const files = deps2.map((id) => {
const r = [add({ id, type: "module" })];
if (id.includes("?import")) {
const [imported] = id.split("?import");
r.push(add({ id: imported, type: "asset" }));
}
return r;
}).flat();
if (source instanceof Uint8Array)
await outputFile(target, source);
else
await outputFile(target, source, { encoding: "utf8" });
return files;
}),
// abort write operation on close event
takeUntil(close$),
concatWith(of([]))
)
);
const close2 = performance.now();
return { start: start2, close: close2, deps };
}
function asRelativeImport(fromFileName, toFileName) {
const path = relative(dirname(fromFileName), toFileName);
return path.startsWith(".") ? path : `./${path}`;
}
function getExternallyConnectableMatch(match) {
if (match === "<all_urls>")
return null;
const parsed = /^(\*|https?):\/\/([^/]+)\/.*$/.exec(match);
if (!parsed)
return null;
const [, , host] = parsed;
if (host === "*")
return null;
return match;
}
function getExternallyConnectableMatches(matches) {
const result = /* @__PURE__ */ new Set();
const unsupported = /* @__PURE__ */ new Set();
for (const match of matches) {
const externallyConnectableMatch = getExternallyConnectableMatch(match);
if (externallyConnectableMatch) {
result.add(externallyConnectableMatch);
} else {
unsupported.add(match);
}
}
return {
matches: [...result],
unsupported: [...unsupported]
};
}
const pluginContentScripts = () => {
const pluginName = "crx:content-scripts";
let server;
let preambleCode;
let hmrTimeout;
let liveReload = true;
let sub = new Subscription();
const worldMainIds = /* @__PURE__ */ new Set();
const worldMainExternallyConnectableMatches = /* @__PURE__ */ new Set();
const unsupportedWorldMainExternallyConnectableMatches = /* @__PURE__ */ new Set();
const findWorldMainIds = async (config, env) => {
const { manifest: _manifest } = await getOptions(config);
const manifest = await (typeof _manifest === "function" ? _manifest(env) : _manifest);
(manifest.content_scripts || []).forEach(({ world, js }) => {
if (world === "MAIN" && js) {
js.forEach((path) => worldMainIds.add(prefix$1("/", path)));
}
});
(manifest.content_scripts || []).forEach(({ world, matches = [] }) => {
if (world === "MAIN") {
const externallyConnectable = getExternallyConnectableMatches(matches);
externallyConnectable.matches.forEach(
(match) => worldMainExternallyConnectableMatches.add(match)
);
externallyConnectable.unsupported.forEach(
(match) => unsupportedWorldMainExternallyConnectableMatches.add(match)
);
}
});
};
const warnUnsupportedWorldMainExternallyConnectableMatches = () => {
if (unsupportedWorldMainExternallyConnectableMatches.size === 0)
return;
const name = `[${pluginName}]`;
const message = pc.yellow(
[
`${name} MAIN world HMR requires externally_connectable.matches. CRX cannot auto-add these Chrome-rejected content-script match patterns:`,
...[...unsupportedWorldMainExternallyConnectableMatches].map(
(match) => ` ${match}`
),
"Add explicit http(s) host addresses to your content script matches during development if you want MAIN world HMR for those pages."
].join("\r\n")
);
console.warn(message);
};
return [
{
name: pluginName,
apply: "serve",
async config(config, env) {
await findWorldMainIds(config, env);
warnUnsupportedWorldMainExternallyConnectableMatches();
const opts = await getOptions(config);
const { contentScripts: contentScripts2 = {} } = opts;
hmrTimeout = contentScripts2.hmrTimeout ?? 5e3;
preambleCode = preambleCode ?? contentScripts2.preambleCode;
liveReload = opts.liveReload !== false;
},
async configureServer(_server) {
server = _server;
if (typeof preambleCode === "undefined" && server.config.plugins.some(
({ name = "none" }) => name.toLowerCase().includes("react") && !name.toLowerCase().includes("preact")
)) {
try {
const react = await import('@vitejs/plugin-react');
preambleCode = react.default.preambleCode;
} catch {
preambleCode = false;
}
}
sub.add(
contentScripts.change$.pipe(filter(RxMap.isChangeType.set)).subscribe(({ value: script }) => {
const { type, id } = script;
if (type === "loader") {
let preamble = { fileName: "" };
if (preambleCode)
preamble = add({ type: "module", id: preambleId });
const client = add({ type: "module", id: viteClientId });
const file = add({ type: "module", id });
const loaderFileName = getFileName({ type: "loader", id });
const loader = add({
type: "asset",
id: loaderFileName,
source: worldMainIds.has(file.id) ? createDevMainLoader({
preamble: preamble.fileName ? asRelativeImport(loaderFileName, preamble.fileName) : "",
client: asRelativeImport(
loaderFileName,
client.fileName
),
fileName: asRelativeImport(
loaderFileName,
file.fileName
)
}) : createDevLoader({
preamble: preamble.fileName,
client: client.fileName,
fileName: file.fileName
})
});
script.fileName = loader.fileName;
} else if (type === "iife") {
const file = add({ type: "iife", id });
script.fileName = file.fileName;
} else {
const file = add({ type: "module", id });
script.fileName = file.fileName;
}
})
);
},
resolveId(source) {
if (source === preambleId)
return preambleId;
if (source === contentHmrPortId)
return contentHmrPortId;
},
load(id) {
if (id === preambleId && typeof preambleCode === "string") {
const defined = preambleCode.replace(/__BASE__/g, server.config.base);
return defined;
}
if (id === contentHmrPortId) {
const defined = contentHmrPort.replace("__CRX_HMR_TIMEOUT__", JSON.stringify(hmrTimeout)).replace("__CRX_LIVE_RELOAD__", JSON.stringify(liveReload)).replace(
"__CRX_HMR_TOKEN__",
JSON.stringify(
getCrxHmrToken(server.config)
)
);
return defined;
}
},
closeBundle() {
sub.unsubscribe();
sub = new Subscription();
},
transformCrxManifest(manifest) {
if (worldMainExternallyConnectableMatches.size === 0)
return null;
manifest.externally_connectable = manifest.externally_connectable ?? {};
manifest.externally_connectable.matches = [
.../* @__PURE__ */ new Set([
...manifest.externally_connectable.matches ?? [],
...worldMainExternallyConnectableMatches
])
];
return manifest;
}
},
{
name: pluginName,
apply: "build",
enforce: "pre",
async config(config, env) {
await findWorldMainIds(config, env);
return {
build: {
rollupOptions: {
// keep exports for content script module api
preserveEntrySignatures: config.build?.rollupOptions?.preserveEntrySignatures ?? "exports-only"
}
}
};
},
generateBundle(_options, bundle) {
finalizeBuildContentScripts(this, bundle, worldMainIds);
}
}
];
};
function finalizeBuildContentScripts(context, bundle, worldMainIds = /* @__PURE__ */ new Set()) {
const processed = /* @__PURE__ */ new Set();
for (const [key, script] of contentScripts) {
if (key !== script.refId || processed.has(script))
continue;
processed.add(script);
if (script.type === "module") {
script.fileName = script.fileName ?? context.getFileName(script.refId);
} else if (script.type === "loader") {
const fileName = script.fileName ?? context.getFileName(script.refId);
script.fileName = fileName;
const bundleFileInfo = bundle[fileName];
if (bundleFileInfo?.type !== "chunk")
continue;
const shouldUseLoader = !(bundleFileInfo.imports.length === 0 && bundleFileInfo.dynamicImports.length === 0 && bundleFileInfo.exports.length === 0);
if (shouldUseLoader) {
if (typeof script.loaderName === "undefined") {
const refId = context.emitFile({
type: "asset",
name: getFileName({
type: "loader",
id: basename(script.id)
}),
source: worldMainIds.has(script.id) ? createProMainLoader({
fileName: `./${fileName.split("/").at(-1)}`
}) : createProLoader({ fileName })
});
script.loaderName = context.getFileName(refId);
}
} else if (typeof script.loaderName === "undefined" && !bundleFileInfo.code.startsWith("(function(){")) {
bundleFileInfo.code = `(function(){${bundleFileInfo.code}})()
`;
}
} else if (script.type === "iife") {
continue;
}
contentScripts.set(script.refId, formatFileData(script));
}
}
const pluginContentScriptsCss = () => {
let injectCss;
return {
name: "crx:content-scripts-css",
enforce: "post",
async config(config) {
const { contentScripts: contentScripts2 = {} } = await getOptions(config);
injectCss = contentScripts2.injectCss ?? true;
},
renderCrxManifest(manifest) {
if (injectCss) {
if (manifest.content_scripts) {
for (const script of manifest.content_scripts)
if (script.js)
for (const fileName of script.js)
if (contentScripts.has(fileName)) {
const { css } = contentScripts.get(fileName);
if (css?.length)
script.css = [script.css ?? [], css].flat();
} else {
throw new Error(
`Content script is undefined by fileName: ${fileName}`
);
}
}
}
return manifest;
}
};
};
const contentCssEntries = /* @__PURE__ */ new Map();
function getContentCssEntries() {
return Array.from(contentCssEntries.values());
}
function clearContentCssEntries() {
contentCssEntries.clear();
}
function registerContentCssEntry(index, cssFiles) {
const virtualId = getContentCssId(index);
const entry = { index, cssFiles, virtualId };
contentCssEntries.set(index, entry);
return entry;
}
const pluginDeclaredContentScripts = () => {
return {
name: "crx:content-scripts-declared-css",
apply: "serve",
resolveId(source) {
if (isContentCssId(source)) {
return source;
}
},
load(id) {
if (!isContentCssId(id))
return;
const index = getContentCssIndex(id);
if (index === null)
return;
const entry = contentCssEntries.get(index);
if (!entry) {
console.warn(
`[crx:content-scripts-declared-css] No CSS entry found for index ${index}`
);
return "";
}
const cssImports = entry.cssFiles.map((cssPath) => {
const importPath = cssPath.startsWith("/") ? cssPath : `/${cssPath}`;
return `import "${importPath}";`;
}).join("\n");
return cssImports + "\n";
}
};
};
fu