@crxjs/vite-plugin
Version:
Build Chrome Extensions with this Vite plugin.
1,343 lines (1,313 loc) • 76 kB
JavaScript
import { simple } from 'acorn-walk';
import { createHash } from 'crypto';
import debug$3 from 'debug';
import { join, normalize, isAbsolute, basename, relative, resolve, dirname, parse } from 'pathe';
import { Subject, filter, ReplaySubject, switchMap, of, startWith, map, 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 convertSourceMap from 'convert-source-map';
import { createLogger, version } from 'vite';
import { readFileSync, existsSync, promises } from 'fs';
import { createRequire } from 'module';
import fg from 'fast-glob';
import pc from 'picocolors';
import { load } from 'cheerio';
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 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 = \"http:\";\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(\"Content-Type\", responseHeaders.get(\"Content-Type\") ?? \"text/javascript\");\n responseHeaders.set(\"Cache-Control\", responseHeaders.get(\"Cache-Control\") ?? \"\");\n return new Response(response.body, {\n headers: responseHeaders\n });\n}\nconst ports = /* @__PURE__ */ new Set();\nchrome.runtime.onConnect.addListener((port) => {\n if (port.name === \"@crx/client\") {\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});\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(`${socketProtocol}://${socketHost}?token=${socketToken}`, \"vite-hmr\");\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 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 handleCrxHmrPayload({\n type: \"custom\",\n event: \"crx:runtime-reload\"\n });\n});\n";
const _debug = (id) => debug$3("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_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 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, "--");
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)}`;
}
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 }) {
if (type === "asset") {
throw new Error(`File type "${type}" not implemented.`);
} else if (type === "iife") {
throw new Error(`File type "${type}" not implemented.`);
} 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 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 pluginBackground = () => {
let config;
let browser;
return [
{
name: "crx:background-client",
apply: "serve",
resolveId(source) {
if (source === `/${workerClientId}`)
return workerClientId;
},
load(id) {
if (id === workerClientId) {
const base = `http://localhost:${config.server.port}/`;
return defineClientValues(
workerHmrClient.replace("__BASE__", JSON.stringify(base)),
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";
},
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 port = config.server.port?.toString();
if (typeof port === "undefined")
throw new Error("server port is undefined in watch mode");
if (browser === "firefox") {
loader = `import('http://localhost:${port}/@vite/env');
`;
loader += `import('http://localhost:${port}${workerClientId}');
`;
if (worker)
loader += `import('http://localhost:${port}/${worker}');
`;
} else {
loader = `import 'http://localhost:${port}/@vite/env';
`;
loader += `import 'http://localhost:${port}${workerClientId}';
`;
if (worker)
loader += `import 'http://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;
}
}
];
};
var contentHmrPort = "function 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 this.port?.disconnect();\n this.port = chrome.runtime.connect({ name: \"@crx/client\" });\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 console.log(\"[crx] runtime reload\");\n setTimeout(() => location.reload(), 500);\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 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";
const contentScripts = new RxMap();
contentScripts.change$.pipe(filter(RxMap.isChangeType.set)).subscribe(({ map, value }) => {
const keyNames = [
"refId",
"id",
"fileName",
"loaderName",
"resolvedId",
"scriptId"
];
for (const keyName of keyNames) {
const key = value[keyName];
if (typeof key === "undefined" || map.has(key)) {
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));
}
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 allFilesReady$ = buildEnd$.pipe(
switchMap(() => outputFiles.change$.pipe(startWith({ type: "start" }))),
map(() => [...outputFiles.values()]),
switchMap((files) => Promise.allSettled(files.map(({ file }) => file)))
);
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) {
return ($) => $.pipe(
// get script contents from dev server
mergeMap(async ({ server }) => {
const target = getOutputPath(server, fileName);
const viteUrl = getViteUrl(script);
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 depSet = new Set(deps);
const magic = new MagicString(code);
for (const i of imports)
if (i.n) {
depSet.add(i.n);
const fileName2 = getFileName({ type: "module", id: i.n });
const fullImport = code.substring(i.s, i.e);
magic.overwrite(i.s, i.e, fullImport.replace(i.n, `/${fileName2}`));
}
return { target, source: magic.toString(), deps: [...depSet] };
})
);
}
async function allFilesReady() {
await firstValueFrom(allFilesReady$);
}
const { outputFile } = fsx;
_debug("file-writer");
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 inputOptions = {
input: "index.html",
...rollupOptions,
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);
let file = outputFiles.get(fileName);
if (typeof file === "undefined") {
file = formatFileData({
...script,
fileName,
file: write(script)
});
outputFiles.set(file.fileName, file);
}
return file;
}
function update(_id) {
const id = prefix$1("/", _id);
const types = ["iife", "module"];
const updatedFiles = [];
for (const type of types) {
const fileName = getFileName({ id, type });
const scriptFile = outputFiles.get(fileName);
if (scriptFile) {
scriptFile.file = write({ id, type });
updatedFiles.push(scriptFile);
outputFiles.set(fileName, scriptFile);
}
}
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 };
}
const pluginContentScripts = () => {
let server;
let preambleCode;
let hmrTimeout;
let sub = new Subscription();
return [
{
name: "crx:content-scripts",
apply: "serve",
async config(config) {
const { contentScripts: contentScripts2 = {} } = await getOptions(config);
hmrTimeout = contentScripts2.hmrTimeout ?? 5e3;
preambleCode = preambleCode ?? contentScripts2.preambleCode;
},
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 (error) {
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 loader = add({
type: "asset",
id: getFileName({ type: "loader", id }),
source: createDevLoader({
preamble: preamble.fileName,
client: client.fileName,
fileName: file.fileName
})
});
script.fileName = loader.fileName;
} else if (type === "iife") {
throw new Error("IIFE content scripts are not implemented");
} 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)
);
return defined;
}
},
closeBundle() {
sub.unsubscribe();
sub = new Subscription();
}
},
{
name: "crx:content-scripts",
apply: "build",
enforce: "pre",
config(config) {
return {
...config,
build: {
...config.build,
rollupOptions: {
...config.build?.rollupOptions,
// keep exports for content script module api
preserveEntrySignatures: config.build?.rollupOptions?.preserveEntrySignatures ?? "exports-only"
}
}
};
},
generateBundle(_options, bundle) {
for (const [key, script] of contentScripts)
if (key === script.refId) {
if (script.type === "module") {
const fileName = this.getFileName(script.refId);
script.fileName = fileName;
} else if (script.type === "loader") {
const fileName = this.getFileName(script.refId);
script.fileName = fileName;
const bundleFileInfo = bundle[fileName];
const shouldUseLoader = !(bundleFileInfo.type === "chunk" && bundleFileInfo.imports.length === 0 && bundleFileInfo.dynamicImports.length === 0 && bundleFileInfo.exports.length === 0);
if (shouldUseLoader) {
const refId = this.emitFile({
type: "asset",
name: getFileName({
type: "loader",
id: basename(script.id)
}),
source: createProLoader({ fileName })
});
script.loaderName = this.getFileName(refId);
} else {
bundleFileInfo.code = `(function(){${bundleFileInfo.code}})()
`;
}
} else if (script.type === "iife") {
throw new Error("IIFE content scripts are not implemented");
}
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 pluginDeclaredContentScripts = () => {
return [];
};
const _dynamicScriptRegEx = /\b(import.meta).CRX_DYNAMIC_SCRIPT_(.+?)[,;]/gm;
const dynamicScriptRegEx = () => {
_dynamicScriptRegEx.lastIndex = 0;
return _dynamicScriptRegEx;
};
const pluginDynamicContentScripts = () => {
let config;
return [
{
name: "crx:dynamic-content-scripts-loader",
enforce: "pre",
configResolved(_config) {
config = _config;
},
configureServer(server) {
return () => {
server.middlewares.use(async (req, res, next) => {
try {
await allFilesReady();
next();
} catch (error) {
let err;
if (error instanceof Error) {
err = error;
} else if (typeof error === "string") {
err = new Error(error);
} else {
err = new Error(
`Unexpected error "${error}" in middleware for "${req.url}"`
);
}
server.ws.send({
type: "error",
err: {
message: err.message,
stack: err.stack ?? "no stack available"
}
});
}
});
};
},
async resolveId(_source, importer) {
if (importer && _source.includes("?script")) {
const url = new URL(_source, "stub://stub");
if (url.searchParams.has("script")) {
const [source] = _source.split("?");
const resolved = await this.resolve(source, importer, {
skipSelf: true
});
if (!resolved)
throw new Error(
`Could not resolve dynamic script: "${_source}" from "${importer}"`
);
const { id } = resolved;
let type = "loader";
if (url.searchParams.has("module")) {
type = "module";
} else if (url.searchParams.has("iife")) {
type = "iife";
}
const scriptId = hashScriptId({ type, id });
const resolvedId = `${id}?scriptId=${scriptId}`;
let script = contentScripts.get(resolvedId);
if (typeof script === "undefined") {
let refId;
let fileName;
let loaderName;
if (config.command === "build") {
refId = this.emitFile({
type: "chunk",
id,
name: basename(id)
});
} else {
refId = scriptId;
const relId = relative(config.root, id);
fileName = getFileName({
type: type === "iife" ? "iife" : "module",
id: relId
});
if (type === "loader")
loaderName = getFileName({ type, id: relId });
}
script = formatFileData({
type,
id: relative(config.root, id),
isDynamicScript: true,
fileName,
loaderName,
refId,
scriptId,
matches: []
});
contentScripts.set(script.id, script);
}
return resolvedId;
} else if (url.searchParams.has("scriptId")) {
return _source;
}
}
},
async load(id) {
const index = id.indexOf("?scriptId=");
if (index > -1) {
const scriptId = id.slice(index + "?scriptId=".length);
const script = contentScripts.get(scriptId);
if (config.command === "build") {
return `export default import.meta.CRX_DYNAMIC_SCRIPT_${script.refId};`;
} else if (typeof script.fileName === "string") {
return `export default ${JSON.stringify(script.fileName)};`;
} else {
throw new Error(
`Content script fileName is undefined: "${script.id}"`
);
}
}
}
},
{
name: "crx:dynamic-content-scripts-build",
apply: "build",
/**
* Replace dynamic script placeholders during build.
*
* Can't use `renderChunk` b/c pre plugin crx:content-scripts uses
* `generateBundle` to emit loaders. Must come after "enforce: pre".
*/
generateBundle(options, bundle) {
for (const chunk of Object.values(bundle))
if (chunk.type === "chunk") {
if (dynamicScriptRegEx().test(chunk.code)) {
const replaced = chunk.code.replace(
dynamicScriptRegEx(),
(match, p1, scriptKey) => {
const script = contentScripts.get(scriptKey);
if (typeof script === "undefined")
throw new Error(
`Content script refId is undefined: "${match}"`
);
if (typeof script.fileName === "undefined")
throw new Error(
`Content script fileName is undefined: "${script.id}"`
);
return `${JSON.stringify(
`/${script.loaderName ?? script.fileName}`
)}${match.split(scriptKey)[1]}`;
}
);
chunk.code = replaced;
}
}
}
}
];
};
const { remove } = fsx;
const logger = createLogger("error", { prefix: "crxjs" });
const pluginFileWriter = () => {
fileWriterError$.subscribe((error) => {
logger.error(error.err.message, { error: error.err });
});
return [
{
name: "crx:file-writer-empty-out-dir",
apply: "serve",
enforce: "pre",
async configResolved(config) {
if (config.build.emptyOutDir) {
await remove(config.build.outDir);
}
}
},
{
name: "crx:file-writer",
apply: "serve",
configureServer(server) {
server.httpServer?.on("listening", async () => {
try {
await start({ server });
} catch (error) {
console.error(error);
server.close();
}
});
server.httpServer?.on("close", () => close());
},
closeBundle() {
outputFiles.clear();
}
}
];
};
const _require = typeof require === "undefined" ? createRequire(import.meta.url) : require;
const customElementsPath = _require.resolve(customElementsId.slice(1));
const customElementsCode = readFileSync(customElementsPath, "utf8");
const customElementsMap = readFileSync(`${customElementsPath}.map`, "utf8");
const pluginFileWriterPolyfill = () => {
return {
name: "crx:file-writer-polyfill",
apply: "serve",
enforce: "pre",
resolveId(source) {
if (source === customElementsId) {
return customElementsId;
}
},
load(id) {
if (id === customElementsId) {
return { code: customElementsCode, map: customElementsMap };
}
},
renderCrxDevScript(code, { type, id }) {
if (type === "module" && id === viteClientId) {
const magic = new MagicString(code);
magic.prepend(`import '${customElementsId}';`);
magic.prepend(`import { HMRPort } from '${contentHmrPortId}';`);
const ws = "new WebSocket";
const index = code.indexOf(ws);
magic.overwrite(index, index + ws.length, "new HMRPort");
return magic.toString();
}
}
};
};
async function manifestFiles(manifest, options = {}) {
let locales = [];
if (manifest.default_locale)
locales = await fg("_locales/**/messages.json", options);
const rulesets = manifest.declarative_net_request?.rule_resources.flatMap(
({ path }) => path
) ?? [];
const contentScripts = manifest.content_scripts?.flatMap(({ js }) => js) ?? [];
const contentStyles = manifest.content_scripts?.flatMap(({ css }) => css);
const serviceWorker = manifest.background && "service_worker" in manifest.background ? manifest.background.service_worker : void 0;
const backgroundScripts = manifest.background && "scripts" in manifest.background ? manifest.background.scripts : void 0;
const background = serviceWorker ? [serviceWorker].filter(isString) : backgroundScripts ? backgroundScripts.filter(isString) : [];
const htmlPages = htmlFiles(manifest);
const icons = [
Object.values(
isString(manifest.icons) ? [manifest.icons] : manifest.icons ?? {}
),
Object.values(
isString(manifest.action?.default_icon) ? [manifest.action?.default_icon] : manifest.action?.default_icon ?? {}
)
].flat();
let webAccessibleResources = [];
if (manifest.web_accessible_resources) {
const resources = await Promise.all(
manifest.web_accessible_resources.flatMap(({ resources: resources2 }) => resources2).map(async (r) => {
if (["*", "**/*"].includes(r))
return void 0;
if (fg.isDynamicPattern(r))
return fg(r, options);
return r;
})
);
webAccessibleResources = [...new Set(resources.flat())].filter(isString);
}
return {
contentScripts: [...new Set(contentScripts)].filter(isString),
contentStyles: [...new Set(contentStyles)].filter(isString),
html: htmlPages,
icons: [...new Set(icons)].filter(isString),
locales: [...new Set(locales)].filter(isString),
rulesets: [...new Set(rulesets)].filter(isString),
background,
webAccessibleResources
};
}
async function dirFiles(dir) {
const files = await fg(join(dir, "**", "*"));
return files;
}
function htmlFiles(manifest) {
const files = [
manifest.action?.default_popup,
Object.values(manifest.chrome_url_overrides ?? {}),
manifest.devtools_page,
manifest.options_page,
manifest.options_ui?.page,
manifest.sandbox?.pages,
manifest.side_panel?.default_path
].flat().filter(isString).map((s) => s.split(/[#?]/)[0]).sort();
return [...new Set(files)];
}
const pluginFileWriterPublic = () => {
let config;
return {
name: "crx:file-writer-public",
apply: "serve",
configResolved(_config) {
config = _config;
},
async generateBundle() {
const publicDir = isAbsolute(config.publicDir) ? config.publicDir : resolve(config.root, config.publicDir);
const files = await dirFiles(publicDir);
for (const filepath of files) {
const source = await readFile$1(filepath);
const fileName = relative(publicDir, filepath);
this.emitFile({ type: "asset", source, fileName });
}
}
};
};
const debug$2 = _debug("file-writer").extend("hmr");
const isCustomPayload = (p) => {
return p.type === "custom";
};
const hmrPayload$ = new Subject();
const crxHMRPayload$ = hmrPayload$.pipe(
filter((p) => !isCustomPayload(p)),
buffer(allFilesReady$),
mergeMap((pps) => {
let fullReload;
const payloads = [];
for (const p of pps)
if (p.type === "full-reload") {
fullReload = p;
} else {
payloads.push(p);
}
if (fullReload)
payloads.push(fullReload);
return payloads;
}),
map((p) => {
switch (p.type) {
case "full-reload": {
const fullReload = {
type: "full-reload",
path: p.path && getViteUrl({ id: p.path, type: "module" })
};
return fullReload;
}
case "prune": {
const prune = {
type: "prune",
paths: p.paths.map((id) => getViteUrl({ id, type: "module" }))
};
return prune;
}
case "update": {
const update = {
type: "update",
updates: p.updates.map(({ acceptedPath: ap, path: p2, ...rest }) => ({
...rest,
acceptedPath: prefix$1("/", getFileName({ id: ap, type: "module" })),
path: prefix$1("/", getFileName({ id: p2, type: "module" }))
}))
};
return update;
}
default:
return p;
}
}),
filter((p) => {
switch (p.type) {
case "full-reload":
return typeof p.path === "undefined";
case "prune":
return p.paths.length > 0;
case "update":
return p.updates.length > 0;
default:
return true;
}
}),
map((data) => {
debug$2(`hmr payload`, data);
return {
type: "custom",
event: "crx:content-script-payload",
data
};
})
);
function isImporter(file) {
const seen = /* @__PURE__ */ new Set();
const pred = (changedNode) => {
seen.add(changedNode);
if (changedNode.file === file)
return true;
for (const parentNode of changedNode.importers) {
const unseen = !seen.has(parentNode);
if (unseen && pred(parentNode))
return true;
}
return false;
};
return pred;
}
const debug$1 = _debug("hmr");
const crxRuntimeReload = {
type: "custom",
event: "crx:runtime-reload"
};
const pluginHMR = () => {
let inputManifestFiles;
let decoratedSend;
let config;
let subs;
return [
{
name: "crx:hmr",
apply: "serve",
enforce: "pre",
// server hmr host should be localhost
async config({ server = {}, ...config2 }) {
if (server.hmr === false)
return;
if (server.hmr === true)
server.hmr = {};
server.hmr = server.hmr ?? {};
server.hmr.host = "localhost";
return { server, ...config2 };
},
// server should ignore outdir
configResolved(_config) {
config = _config;
const { watch = {} } = config.server;
config.server.watch = watch;
watch.ignored = watch.ignored ? [...new Set([watch.ignored].flat())] : [];
const outDir = isAbsolute(config.build.outDir) ? config.build.outDir : join(config.root, config.build.outDir, "**/*");
if (!watch.ignored.includes(outDir))
watch.ignored.push(outDir);
},
configureServer(server) {
if (server.ws.send !== decoratedSend) {
const { send } = server.ws;
decoratedSend = (payload) => {
if (payload.type === "error") {
send({
type: "custom",
event: "crx:content-script-payload",
data: payload
});
} else {
hmrPayload$.next(payload);
}
send(payload);
};
server.ws.send = decoratedSend;
subs = new Subscription(() => subs = new Subscription());
subs.add(fileWriterError$.subscribe(send));
subs.add(
crxHMRPayload$.subscribe((payload) => {
send(payload);
})
);
}
},
closeBundle() {
subs.unsubscribe();
},
// background changes require a full extension reload
handleHotUpdate({ modules, server }) {
const { root } = server.config;
const relFiles = /* @__PURE__ */ new Set();
const fsFiles = /* @__PURE__ */ new Set();
for (const m of modules) {
if (m.id?.startsWith(root)) {
relFiles.add(m.id.slice(server.config.root.length));
} else if (m.url?.startsWith("/@fs")) {
fsFiles.add(m.url);
}
}
fsFiles.forEach((file) => update(file));
if (inputManifestFiles.background.length) {
const background = prefix$1("/", inputManifestFiles.background[0]);
if (relFiles.has(background) || modules.some(isImporter(join(server.config.root, background)))) {
debug$1("sending runtime reload");
server.ws.send(crxRuntimeReload);
}
}
for (const [key, script] of contentScripts)
if (key === script.id) {
if (relFiles.has(script.id) || modules.some(isImporter(join(server.config.root, script.id)))) {
relFiles.forEach((relFile) => update(relFile));
}
}
}
},
{
name: "crx:hmr",
apply: "serve",
enforce: "post",
// get final output manifest for handleHotUpdate 👆
async transformCrxManifest(manifest) {
inputManifestFiles = await manifestFiles(manifest, { cwd: config.root });
return null;
},
renderCrxDevScript(code, { id: _id, type }) {
if (type === "module" && _id !== "/@vite/client" && code.includes("createHotContext")) {
const id = _id.replace(/t=\d+&/, "");
const escaped = id.replace(/([?&.])/g, "\\$1");
const regexp = new RegExp(
`(?<=createHotContext\\(")${escaped}(?="\\))`
);
const fileUrl = prefix$1("/", getFileName({ id, type }));
const replaced = code.replace(regexp, fileUrl);
return replaced;
} else {
return code;
}
}
}
];
};
function printStr(dir) {
return ` ${pc.magentaBright("B R O W S E R")}
${pc.greenBright("E X T E N S I O N")}
${pc.blueBright("T O O L S")}
${pc.green("\u279C")} ${pc.bold("CRXJS")}: ${pc.green(`Load ${pc.cyan(dir)} as unpacked extension`)}`;
}
const pluginPrint = () => {
let outDir = "dist";
return [
{
name: "crx:print",
enforce: "pre",
configResolved(resolvedConfig) {
outDir = resolvedConfig.build.outDir;
},
configureServer(server) {
server.printUrls = () => {
console.log(printStr(outDir));
};
}
}
];
};
var loader = "try {\n for (const p of JSON.parse(SCRIPTS)) {\n const url = new URL(p, \"https://stub\");\n url.searchParams.set(\"t\", Date.now().toString());\n const req = url.pathname + url.search;\n await import(\n /* @vite-ignore */\n req\n );\n }\n} catch (error) {\n console.error(error);\n}\n";
const pluginName = "crx:html-inline-scripts";
const debug = _debug(pluginName);
const prefix = "@crx/inline-script";
const isInlineTag = (t) => t.tag === "script" && !t.attrs?.src;
const toKey = (ctx) => {
const { dir, name } = parse(ctx.path);
return join(prefix, dir, name);
};
const pluginHtmlInlineScripts = () => {
const pages = /* @__PURE__ */ new Map();
const auditTransformIndexHtml = (p) => {
let transform;
if (typeof p.transformIndexHtml === "function") {
transform = p.transformIndexHtml;
p.transformIndexHtml = auditor;
} else if (typeof p.transformIndexHtml === "object") {
transform = p.transformIndexHtml.transform;
p.transformIndexHtml.transform = auditor;
}
async function auditor(_html, ctx) {
const result = await transform(_html, ctx);
if (!result || typeof result === "string")
return result;
let html;
let tags;
if (Array.isArray(result)) {
tags = new Set(result);
} else {
tags = new Set(result.tags);
html = result.html;
}
const scripts = [];
for (const t of tags)
if (t.tag === "script") {
tags.delete(t);
scripts.push(t);
}
const key = toKey(ctx);
const page = pag