UNPKG

@crxjs/vite-plugin

Version:

Build Chrome Extensions with this Vite plugin.

1,250 lines (1,227 loc) 118 kB
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