#!/usr/bin/env node /** * 构建管线:tsc 只做类型检查(--noEmit),esbuild 负责产出 dist/。 * * 产物分三类,对应三种运行环境,不能混在同一个 bundle 里: * 1. 全局脚本(iife):utils / env-store / crypto-store * 通过 globalThis 暴露 API,被 content script 直接读全局、被 background 用 * importScripts 加载,也必须能被 Node 测试 require,因此各自独立成文件。 * 2. 独立入口(iife):background / content * 运行期自行 importScripts 或读上面的全局变量,esbuild 不会(也不能)跟踪它们。 * 3. 模块入口(esm,可打包):popup/main * 由 popup.html 以 type="module" 加载,其静态依赖(i18n / strength / messaging / * ui-utils / crypto-file / popup/*)全部内联进 dist/popup/main.js。 * * 用法: * node scripts/build.mjs 开发构建(带 sourcemap,不压缩) * node scripts/build.mjs --release 发布构建(压缩,无 sourcemap) * node scripts/build.mjs --watch 监听重建 */ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as esbuild from "esbuild"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const DIST = path.join(ROOT, "dist"); /** 全局脚本:必须独立成文件,iife 格式,彼此通过 globalThis 通信 */ const GLOBAL_SCRIPTS = ["utils", "permissions", "env-store", "crypto-store", "key-store"]; /** 独立入口:各自成文件,不做跨文件打包 */ const STANDALONE_SCRIPTS = ["background", "content"]; /** 由 background 通过 chrome.scripting 按需注入的脚本(manifest 里不再静态声明) */ const INJECTED_ASSETS = ["dist/utils.js", "dist/content.js"]; function entryPoints() { return [ // UMD 全局脚本:不打包、不指定 format,原样保留 module.exports 分支 ...GLOBAL_SCRIPTS.map((name) => ({ in: `src/${name}.ts`, out: `dist/${name}.js`, bundle: false })), // 独立入口:不打包(运行期靠 importScripts / 全局变量协作) ...STANDALONE_SCRIPTS.map((name) => ({ in: `src/${name}.ts`, out: `dist/${name}.js`, bundle: false })), // popup:真正的 ESM 打包,静态依赖全部内联 { in: "src/popup/main.ts", out: "dist/popup/main.js", format: "esm", bundle: true }, ]; } /** 类型检查单独一步,确保类型错误不会被 esbuild 静默跳过 */ function typecheck() { const tscBin = path.join(ROOT, "node_modules", "typescript", "bin", "tsc"); execFileSync(process.execPath, [tscBin, "--noEmit"], { cwd: ROOT, stdio: "inherit" }); } function esbuildOptions({ release, format, bundle }) { const options = { bundle, platform: "browser", target: "chrome114", charset: "utf8", minify: release, sourcemap: release ? false : "linked", legalComments: "none", logLevel: "warning", }; // 只在需要时指定 format:一旦指定 iife,esbuild 会把 UMD 的 module.exports 换成 // 自己的合成模块对象,Node 测试 require 就取不到导出内容 if (format) options.format = format; return options; } /** manifest.json / popup.html 里引用到的文件必须真实存在,避免"构建成功但装不上" */ async function verifyReferences() { const manifest = JSON.parse(await readFile(path.join(ROOT, "manifest.json"), "utf8")); const refs = new Set(); const add = (p) => { if (typeof p === "string") refs.add(p); }; add(manifest.background?.service_worker); add(manifest.side_panel?.default_path); Object.values(manifest.icons || {}).forEach(add); Object.values(manifest.action?.default_icon || {}).forEach(add); (manifest.content_scripts || []).forEach((cs) => (cs.js || []).forEach(add)); // manifest 不再静态声明 content_scripts,这些文件由 background 按需注入,同样必须存在 INJECTED_ASSETS.forEach(add); const html = await readFile(path.join(ROOT, "popup.html"), "utf8"); for (const m of html.matchAll(/(?:src|href)="([^"]+)"/g)) { const ref = m[1]; if (/^(https?:)?\/\//.test(ref) || ref.startsWith("#") || ref.startsWith("data:")) continue; refs.add(ref); } const missing = [...refs].filter((ref) => !existsSync(path.join(ROOT, ref))); if (missing.length) { throw new Error(`以下被引用的文件不存在,构建产物不完整:\n - ${missing.join("\n - ")}`); } return [...refs]; } /** manifest.json 是版本号的唯一来源,package.json 需与之保持一致 */ async function checkVersionConsistency() { const manifest = JSON.parse(await readFile(path.join(ROOT, "manifest.json"), "utf8")); const pkg = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8")); if (manifest.version !== pkg.version) { console.warn( `[build] 版本号不一致:manifest.json=${manifest.version} package.json=${pkg.version}(执行 npm run release 可同步)`, ); } return manifest.version; } export async function buildAll({ release = false, watch = false } = {}) { typecheck(); const version = await checkVersionConsistency(); if (watch) { const contexts = await Promise.all( entryPoints().map((ep) => esbuild.context({ ...esbuildOptions({ release, format: ep.format, bundle: ep.bundle }), entryPoints: [ep.in], outfile: ep.out, }), ), ); await Promise.all(contexts.map((c) => c.watch())); console.log(`[build] 监听中… v${version}(Ctrl+C 退出)`); return { version, watching: true }; } await rm(DIST, { recursive: true, force: true }); // 清掉历史产物,避免残留旧文件被打进包 for (const ep of entryPoints()) { await esbuild.build({ ...esbuildOptions({ release, format: ep.format, bundle: ep.bundle }), entryPoints: [ep.in], outfile: ep.out, }); } const refs = await verifyReferences(); console.log( `[build] v${version} ${release ? "release" : "dev"} 构建完成:${entryPoints().length} 个入口,校验 ${refs.length} 处引用`, ); return { version, refs }; } const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isDirectRun) { const args = process.argv.slice(2); await buildAll({ release: args.includes("--release"), watch: args.includes("--watch") }); }