UNPKG

@alloc/html-bundle

Version:

Bundle your HTML assets with Esbuild and LightningCSS. Custom plugins, HMR platform, and more.

403 lines 15.5 kB
import { createScript, findElement, insertBefore } from '@web/parse5-utils'; import chromeRemote from 'chrome-remote-interface'; import exitHook from 'exit-hook'; import fs from 'fs'; import { cyan, yellow } from 'kleur/colors'; import path from 'path'; import { cmd as webExtCmd } from 'web-ext'; import { findFreeTcpPort, resolveHome, toArray } from '../utils.mjs'; const polyfillPath = path.resolve(import.meta.url.replace(/^file:/, ''), '../../../node_modules/webextension-polyfill/dist/browser-polyfill.min.js'); export const webextPlugin = (config, flags) => { const webextConfig = config.webext; if (webextConfig.polyfill) { config.copy.push({ [polyfillPath]: 'browser-polyfill.min.js', [polyfillPath + '.map']: 'browser-polyfill.min.js.map', }); } return { async buildEnd() { if (!flags.watch) { // Pack the web extension for distribution. await enableWebExtension(webextConfig, config, flags); } }, document(root) { if (webextConfig.polyfill) { const head = findElement(root, e => e.tagName == 'head'); const polyfillScript = createScript({ src: path.join('/', config.build, 'browser-polyfill.min.js'), }); insertBefore(head, polyfillScript, head.childNodes[0]); } }, hmr(clients) { enableWebExtension(webextConfig, config, flags, clients); clients.on('connect', ({ client }) => { client .evaluate('[location.protocol, location.host]') .then(([protocol, host]) => { client.emit('webext:uuid', { protocol, host }); }); }); }, }; }; function parseContentSecurityPolicy(str) { const policies = str.split(/ *; */); const result = {}; for (const policy of policies) { if (!policy) continue; const [name, ...values] = policy.split(/ +/); result[name] = new Set(values); } Object.defineProperty(result, 'toString', { value: () => { return (Object.entries(result) .map(([name, values]) => `${name} ${[...values].join(' ')}`) .join('; ') + ';'); }, }); return result; } async function enableWebExtension(webextConfig, config, flags, clients) { let isManifestChanged = false; const rawManifest = fs.readFileSync('manifest.json', 'utf8'); const manifest = JSON.parse(rawManifest); if (flags.watch) { const httpServerUrl = config.server.url.href; const wsServerUrl = httpServerUrl.replace('http', 'ws'); // The content security policy needs to be lax for HMR to work. const csp = parseContentSecurityPolicy(manifest.content_security_policy || ''); csp['default-src'] ||= new Set(["'self'"]); csp['default-src'].add(httpServerUrl); csp['connect-src'] ||= new Set(csp['default-src']); csp['connect-src'].add(httpServerUrl); csp['connect-src'].add(wsServerUrl); csp['script-src'] ||= new Set(csp['default-src'] || ["'self'"]); csp['script-src'].add(httpServerUrl); csp['style-src'] ||= new Set(csp['default-src'] || ["'self'"]); csp['style-src'].add(httpServerUrl); csp['style-src'].add("'unsafe-inline'"); manifest.content_security_policy = csp.toString(); isManifestChanged = true; } if (webextConfig.polyfill) { const polyfillPath = path.join(config.build, 'browser-polyfill.min.js'); const injectPolyfillIfNeeded = (scripts) => { if (!scripts) return; const needsBrowserPolyfill = scripts.some(file => { const code = fs.readFileSync(file, 'utf8'); return /\bbrowser\./.test(code); }); if (needsBrowserPolyfill) { scripts.unshift(polyfillPath); isManifestChanged = true; } }; injectPolyfillIfNeeded(manifest.background?.scripts); manifest.content_scripts?.forEach((script) => { injectPolyfillIfNeeded(script.js); }); } for (const plugin of config.plugins) { if (!plugin.webext) continue; isManifestChanged = (await plugin.webext(manifest, webextConfig)) || isManifestChanged; } if (isManifestChanged) { // Save our changes… fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2)); // …but revert them once we exit. exitHook(() => { fs.writeFileSync('manifest.json', rawManifest); }); } const ignoredFiles = new Set(fs.readdirSync(process.cwd())); const keepFile = (file, watch = !!file && !file.startsWith(config.build + '/')) => { if (typeof file == 'string') { ignoredFiles.delete(file.split('/')[0]); if (watch && fs.existsSync(file)) { config.watcher?.add(file); } } }; const keepFiles = (arg) => typeof arg == 'string' ? keepFile(arg) : Array.isArray(arg) ? arg.forEach(keepFiles) : arg && Object.values(arg).forEach(keepFiles); keepFile('manifest.json'); keepFile(config.build, false); keepFile(config.assets); keepFile(manifest.browser_action?.default_popup); keepFiles(manifest.background?.scripts); keepFiles(manifest.browser_action?.default_icon); keepFiles(manifest.chrome_url_overrides); keepFiles(manifest.content_scripts); keepFiles(manifest.icons); const artifactsDir = webextConfig.artifactsDir || path.join(process.cwd(), 'web-ext-artifacts'); if (flags.watch) { const runConfig = webextConfig.run || {}; const firefoxConfig = runConfig.firefox || {}; const chromiumConfig = runConfig.chromium || {}; let targets = toArray(runConfig.target || 'chromium'); if (flags.webext) { const filter = toArray(flags.webext); targets = targets.filter(target => filter.some(prefix => target.startsWith(prefix))); } const tabs = toArray(runConfig.startUrl || 'about:newtab'); // Always run chromium first, as it's faster to launch. for (const target of targets.sort()) { let port; const params = {}; if (target == 'chromium') { params.chromiumBinary = resolveHome(chromiumConfig.binary); params.chromiumProfile = resolveHome(chromiumConfig.profile); params.args = chromiumConfig.args; if (chromiumConfig.keepProfileChanges) { params.keepProfileChanges = true; } } else if (target == 'firefox-desktop') { params.firefox = resolveHome(firefoxConfig.binary || 'firefox'); params.firefoxProfile = resolveHome(firefoxConfig.profile); params.firefoxPreview = []; params.preInstall = !!firefoxConfig.preInstall; params.devtools = !!firefoxConfig.devtools; params.browserConsole = !!firefoxConfig.browserConsole; if (firefoxConfig.keepProfileChanges) { params.keepProfileChanges = true; } const args = (params.args = firefoxConfig.args || []); port = await findFreeTcpPort(); args.push('--remote-debugging-port', port.toString()); } params.keepProfileChanges ??= runConfig.keepProfileChanges ?? false; if (params.chromiumProfile || params.firefoxProfile) { params.profileCreateIfMissing = true; } const runner = await webExtCmd.run({ ...params, target: [target], sourceDir: process.cwd(), artifactsDir, noReload: true, }); await refreshOnRebuild(target, runner, config, clients, manifest, tabs, port).catch(e => { console.error('[%s] Error during setup:', target, e.message.includes('404 Not Found') ? 'Unsupported CDP command' : e.message); }); } } else { await webExtCmd.build({ sourceDir: process.cwd(), artifactsDir, ignoreFiles: [...ignoredFiles], overwriteDest: true, }); } } async function refreshOnRebuild(target, runner, config, clients, manifest, tabs, firefoxPort) { let port; let extProtocol; const isChromium = target == 'chromium'; if (isChromium) { const instance = runner.extensionRunners[0].chromiumInstance; port = instance.port; extProtocol = 'chrome-extension:'; // For some reason, the Chrome process may stay alive if we don't // kill it explicitly. exitHook(() => { instance.process.kill(); }); } else if (firefoxPort) { port = firefoxPort; extProtocol = 'moz-extension:'; } else { return; } if (tabs.length) { let resolvedTabs = tabs; if (target == 'firefox-desktop') { resolvedTabs = resolveFirefoxTabs(tabs, manifest, runner); } else { resolvedTabs = tabs.map(url => url == 'about:newtab' ? 'chrome://newtab/' : url); } await openTabs(port, resolvedTabs, manifest, isChromium); } let uuid; clients.on('webext:uuid', event => { if (event.protocol == extProtocol) { uuid = event.id; } }); if (isChromium) { // Ensure not all tabs will be closed as a result of the extension // being reloaded, since that will cause an unsightly reopening of // the browser window. config.events.on('will-rebuild', async () => { const extOrigin = extProtocol + '//' + uuid; const pages = (await chromeRemote.List({ port })).filter(tab => tab.type == 'page'); if (pages.length > 0 && pages.every(tab => tab.url.startsWith(extOrigin))) { const firstPage = await chromeRemote({ port, target: pages[0].id, }); await firstPage.send('Page.navigate', { url: 'chrome://newtab/', }); } }); } config.events.on('rebuild', async () => { const extOrigin = extProtocol + '//' + uuid; if (!uuid) { console.warn('[%s] ' + yellow('⚠'), target, 'Extension UUID not found'); return; } console.log(cyan('↺'), extOrigin); // Chromium reloads automatically, and we can't stop it. if (!isChromium) { await runner.reloadAllExtensions(); } const newTabPage = manifest.chrome_url_overrides?.newtab; const newTabUrl = newTabPage ? `${extOrigin}/${newTabPage}` : isChromium ? 'chrome://newtab/' : 'about:newtab'; const currentTabs = await chromeRemote.List({ port }); const missingTabs = tabs .map(url => (newTabPage && url == 'about:newtab' ? newTabUrl : url)) .filter(url => { const matchingTab = currentTabs.find(tab => tab.url == url); return !matchingTab || url == newTabUrl; }); if (missingTabs.length) { try { await openTabs(port, missingTabs, manifest, isChromium, true); } catch (e) { console.error(e.message); } } }); } function resolveFirefoxTabs(tabs, manifest, runner) { return tabs.map((url) => { if (url != 'about:newtab') { return url; } const newTabPage = manifest.chrome_url_overrides?.newtab; if (newTabPage) { const profilePath = runner.extensionRunners[0].profile?.path(); if (profilePath) { const uuid = extractFirefoxExtensionUUID(profilePath, manifest); if (uuid) { return `moz-extension://${uuid}/${newTabPage}`; } } } return url; }); } function extractFirefoxExtensionUUID(profile, manifest) { try { const rawPrefs = fs.readFileSync(path.join(profile, 'prefs.js'), 'utf8'); const uuids = JSON.parse((rawPrefs.match(/user_pref\("extensions\.webextensions\.uuids",\s*"(.*?)"\);/)?.[1] || '{}').replace(/\\(\\)?/g, '$1')); const geckoId = manifest.browser_specific_settings?.gecko?.id; if (geckoId) { return uuids[geckoId]; } } catch (e) { console.error(e); } return null; } async function openTabs(port, tabs, manifest, isChromium, isRefresh) { const targets = await retryForever(() => chromeRemote.List({ port })); const firstTab = targets.find(t => t.type == 'page'); const browser = await chromeRemote({ port, target: targets[0], }); await Promise.all(tabs.map(async (url, i) => { let target; let targetId; let needsNavigate = false; if (i == 0 && firstTab) { targetId = firstTab.id; needsNavigate = true; } else { let params; if (isChromium) { params = { url }; } else { // Firefox doesn't support creating a new tab with a specific // URL => https://bugzilla.mozilla.org/show_bug.cgi?id=1817258 needsNavigate = true; } targetId = (await browser.send('Target.createTarget', params)).targetId; } target = await chromeRemote({ port, target: targetId, }); if (needsNavigate) { await target.send('Page.navigate', { url }); } if (!isRefresh) { return; } const newTabPage = manifest.chrome_url_overrides?.newtab; const isNewTab = !!newTabPage && url.endsWith('/' + newTabPage); if (!isNewTab) { return; } let retries = 0; while (true) { const { result } = await target.send('Runtime.evaluate', { expression: 'location.href', }); if (url == result.value) { break; } const delay = 100 ** (1 + 0.1 * retries++); console.log('Expected "%s" to be "%s". Retrying in %s secs...', result.value, url, (delay / 1000).toFixed(1)); await new Promise(resolve => setTimeout(resolve, delay)); await target.send('Page.navigate', { url: isChromium ? 'chrome://newtab/' : url, }); } })); } async function retryForever(task) { const start = Date.now(); while (true) { try { return await task(); } catch (err) { // console.error( // err.message.includes('404 Not Found') ? 'Browser not ready' : err // ) if (Date.now() - start > 3000) { throw err; } } } } //# sourceMappingURL=webext.mjs.map