/** * scripts/size.mjs — 发行版字节预算「增量法」测量器(core-protocol-v0) * * 口径(执行版 §5,与 channel-spike 同策略,写死不得擅改): * - 打包:复用 build.mjs 的锁定参数(bundle+minify+iife+es2019+sourcemap=false); * - gz:**zlib.gzipSync(buf, { level: 9 })**(node 内置;等价 `gzip -9`,但纯内置免外部进程); * - **增量法**:基线壳 → +core → 逐插件叠加,每步实测「当前累计 bundle 的 raw/gz」, * 相邻两步差值 = 该新增件的「增量 raw/gz」(树摇后的真实净增,非孤立打包值); * - 输出 `SIZES.md`:分项 raw/gz 双列 + 命令注记 + 各插件 manifest 的 gzBudgetBytes 对照(超配额标 ⚠️)。 * * ⚠️ 运行环境:真实打包需 esbuild → **仅集成段(mini-desktop)跑**;本机无 esbuild 会报指引。 * gz 量算(zlib)逻辑本机可独立自检:`node scripts/size.mjs --selftest`(不需 esbuild)。 * * 用法(集成段):node scripts/size.mjs → 生成 game-runtime/SIZES.md * 本机自检 :node scripts/size.mjs --selftest */ 'use strict'; import { gzipSync } from 'node:zlib'; import { writeFileSync, readFileSync, readdirSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { LOCKED_BUILD_OPTIONS } from './build.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const PLUGINS_DIR = join(ROOT, 'src', 'plugins'); const CORE_ENTRY = join(ROOT, 'src', 'core', 'plugin.js'); /** * 量算一段 buffer 的 raw 与 gz(level 9)字节数。这是 SIZES 的最底层口径,纯内置可独立测。 * @param {Buffer|Uint8Array|string} content * @returns {{ raw: number, gz: number }} */ export function measureBytes(content) { const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); return { raw: buf.length, gz: gzipSync(buf, { level: 9 }).length }; } /** * 用锁定参数把一组入口打成「单 iife bundle」并返回其字节数(增量法的一档)。 * 集成段用;需 esbuild。多入口时用一个聚合虚拟入口把它们 import 到一起,量整包累计大小。 * @param {string[]} entries 该档要叠加进来的入口文件(绝对路径) * @param {object} esbuild 已 import 的 esbuild 模块 * @returns {Promise<{ raw:number, gz:number }>} */ async function bundleSize(entries, esbuild) { if (entries.length === 0) return { raw: 0, gz: 0 }; // 聚合入口:把多个文件 import 进一个临时入口,模拟「整包累计」。 const tmp = mkdtempSync(join(tmpdir(), 'rt-size-')); try { const aggregate = join(tmp, 'aggregate.js'); const body = entries.map((e, i) => `import * as m${i} from ${JSON.stringify(e)};`).join('\n') + '\n// 防树摇整体消除:把各模块挂到全局副作用点。\n' + 'globalThis.__rt_keep = [' + entries.map((_, i) => `m${i}`).join(', ') + '];\n'; writeFileSync(aggregate, body); const out = await esbuild.build({ ...LOCKED_BUILD_OPTIONS, entryPoints: [aggregate], bundle: true, write: false, // 不落盘,直接拿产物 buffer 量字节 globalName: '__RT_SIZE_PROBE', }); const code = out.outputFiles[0].contents; // Uint8Array return measureBytes(Buffer.from(code)); } finally { rmSync(tmp, { recursive: true, force: true }); } } /** 扫描 src/plugins/<*>(排除 _example 可选)→ 返回 [{name, entry, budget}],按 name 排序稳定。 */ function collectPlugins() { const list = []; if (!existsSync(PLUGINS_DIR)) return list; for (const name of readdirSync(PLUGINS_DIR).sort()) { const dir = join(PLUGINS_DIR, name); const mf = join(dir, 'manifest.json'); if (!existsSync(mf)) continue; const manifest = JSON.parse(readFileSync(mf, 'utf8')); const impl = join(dir, (manifest.exports && manifest.exports.impl) || 'impl.js'); list.push({ name: manifest.name, dir: name, entry: impl, budget: manifest.gzBudgetBytes }); } return list; } /** 生成 SIZES.md 文本。 */ function renderSizesMd(rows, baseline) { const lines = []; lines.push('# SIZES.md —— 发行版字节预算实测(增量法,core-protocol-v0)'); lines.push(''); lines.push('> 由 `scripts/size.mjs` 在集成段(mini-desktop)生成。**勿手改**,改源后重跑覆盖。'); lines.push('> 口径:esbuild `bundle+minify+format=iife+target=es2019+sourcemap=false`;'); lines.push('> gz = `zlib.gzipSync(buf,{level:9})`(等价 `gzip -9`);**增量法** = 基线壳→+core→逐插件叠加,差值为各件净增。'); lines.push(''); lines.push('| 叠加项 | 累计 raw | 累计 gz | 增量 raw | 增量 gz | gz 预算 | 判定 |'); lines.push('|---|--:|--:|--:|--:|--:|:--|'); lines.push(`| 基线壳 | ${baseline.raw} | ${baseline.gz} | — | — | — | 基准 |`); let totalGz = 0; for (const r of rows) { const verdict = r.budget == null ? '—' : r.deltaGz <= r.budget ? `✔ ≤${r.budget}` : `⚠️ 超 ${r.deltaGz - r.budget}B`; if (r.budget != null) totalGz += r.deltaGz; lines.push(`| ${r.label} | ${r.cumRaw} | ${r.cumGz} | ${r.deltaRaw} | ${r.deltaGz} | ${r.budget ?? '—'} | ${verdict} |`); } lines.push(''); lines.push(`合计插件 gz 增量 ≈ **${totalGz}B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。`); lines.push(''); return lines.join('\n') + '\n'; } /** --selftest:仅验 zlib gz 量算口径(不需 esbuild,本机可跑留证)。 */ function selftest() { const cases = ['', 'a', 'hello world', 'x'.repeat(1000)]; console.log('[size --selftest] zlib gzipSync(level:9) 量算自检:'); for (const s of cases) { const m = measureBytes(s); // 基本不变式:raw=字节长度;gz>0(gzip 头即非零);gz 对高度可压缩串应远小于 raw。 if (m.raw !== Buffer.from(s).length) throw new Error('raw 口径错'); if (m.gz <= 0) throw new Error('gz 应 >0'); console.log(` len=${String(m.raw).padStart(4)} raw=${m.raw} gz=${m.gz}`); } // 可压缩性断言:1000 个相同字符 gz 必远小于 raw。 const big = measureBytes('x'.repeat(1000)); if (!(big.gz < big.raw / 5)) throw new Error('高度可压缩串 gz 未显著小于 raw,口径可疑'); console.log('[size --selftest] OK:gz 量算口径正确(高压缩串 gz << raw)。'); } // ── 主流程 ────────────────────────────────────────────────────────────────── async function main() { if (process.argv.includes('--selftest')) { selftest(); return; } let esbuild; try { esbuild = await import('esbuild'); } catch { console.error( '[size] 未找到 esbuild —— 增量法实测仅在集成段(mini-desktop)跑。\n' + ' 请在集成段执行:npm i -D esbuild,再重跑生成 SIZES.md。\n' + ' 本机仅可跑 gz 量算自检:node scripts/size.mjs --selftest' ); process.exit(1); } // 基线壳:本波无独立「空壳」入口,以「core 之前」为 0 基线(增量法起点)。 // 若后续有引擎壳入口,可在此把 baseline 换成壳的实测值(口径不变)。 const baseline = { raw: 0, gz: 0 }; // 第一档:+core const coreSize = await bundleSize([CORE_ENTRY], esbuild); const rows = []; rows.push({ label: '+core', cumRaw: coreSize.raw, cumGz: coreSize.gz, deltaRaw: coreSize.raw - baseline.raw, deltaGz: coreSize.gz - baseline.gz, budget: null, }); // 逐插件叠加(累计 = core + 已叠插件 + 本插件),差值 = 本插件增量。 const plugins = collectPlugins(); let prev = coreSize; const cumulativeEntries = [CORE_ENTRY]; for (const p of plugins) { cumulativeEntries.push(p.entry); const cur = await bundleSize(cumulativeEntries, esbuild); rows.push({ label: `+${p.name}`, cumRaw: cur.raw, cumGz: cur.gz, deltaRaw: cur.raw - prev.raw, deltaGz: cur.gz - prev.gz, budget: p.budget, }); prev = cur; } const md = renderSizesMd(rows, baseline); const outPath = join(ROOT, 'SIZES.md'); writeFileSync(outPath, md); console.log(`[size] 已写 ${outPath}(${plugins.length} 插件,增量法)`); } main().catch((e) => { console.error('[size] 失败:' + (e && e.message ? e.message : e)); process.exit(1); });