dcb6ad693b
.worker-builds/ 是本腳本的輸出,在成品寫出、commit 之前永遠是 untracked—— 拿它判斷「原始碼乾不乾淨」是自己把自己判成髒的假陽性。git status pathspec 排除 .worker-builds 後才是「原始碼有沒有未 commit 變更」的真實訊號。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* build-worker-artifacts.mjs — Arcrun#80:tier2 worker(TS→ 可部署 JS)的**唯一官方編譯點**。
|
||
*
|
||
* 背景(Arcrun#80/arcrun-rag#39):這個 repo 過去只把 tier1 零件(TinyGo→wasm)的成品
|
||
* commit 進 `.component-builds/{name}/component.wasm`;tier2(cypher-executor / kbdb /
|
||
* http_request / code / mcp 這五顆 TS worker)只有原始碼,沒有編好的成品。於是
|
||
* arcrun-rag 的安裝器只好自己在**它那邊**跑 esbuild(`installer/scripts/build-bundles.mjs`),
|
||
* 結果同一份原始碼在不同機器編出不同位元組(見下「已知踩坑」),
|
||
* 而且「這顆成品是哪個 commit 編的」只有整包一個 `source` 欄位,答不出單顆的來源。
|
||
*
|
||
* 本腳本要解的:
|
||
* 1. 編譯只發生在這裡(Arcrun 本體),成品放固定位置 `.worker-builds/`,commit 進 repo——
|
||
* 與 `.component-builds/*.wasm` 同一個既有慣例(self-host 用戶從 repo 直接拿部署來源)。
|
||
* 2. 每顆成品自己記得「我是哪個 commit 編出來的」(`source_commit`,答到單顆目錄層級,
|
||
* 不是整包一個欄位)。
|
||
* 3. 同一個 commit、任何人任何時候編,位元組要相同——見下方兩個已知踩坑與對應解法。
|
||
*
|
||
* 已知踩坑(2026-08-10 實錄,docs-site/.../changelog.md 1.4.33 段;已回報 arcrun-rag#72):
|
||
* ① esbuild 的 bundle 輸出會把「入口路徑」寫進產物內部的檔案邊界註解,而該路徑預設是
|
||
* **相對於 esbuild 執行時的 cwd**——雲端容器跑在 `.../arcrun/`、地端跑在
|
||
* `.../matrix/arcrun/`,同一份原始碼因此編出不同註解。
|
||
* 解法:固定 `absWorkingDir` 為**本腳本自己算出的 repo 根目錄**(不吃外部 cwd/env
|
||
* 路徑),entry 一律用「相對 repo 根目錄」的相對路徑餵給 esbuild——不管這個 clone
|
||
* 實際被放在磁碟的哪個絕對路徑下,esbuild 內部算出的相對路徑字串都相同。
|
||
* ② 各 worker 目錄的 node_modules 若用不同套件管理器(pnpm store vs npm 平鋪)安裝,
|
||
* 可能夾帶不同版本的間接依賴(實測 ajv/uri-js 差 1019 行)。
|
||
* 解法:本腳本**不自己 npm/pnpm install**——強制要求呼叫者先用「該目錄既有的
|
||
* lockfile」(pnpm-lock.yaml 用 `pnpm install --frozen-lockfile`;package-lock.json
|
||
* 用 `npm ci`)裝好 node_modules,並在建置前檢查 lockfile 是否存在,
|
||
* lockfile 是「同一份依賴圖」的機械保證,比信任「兩台機器裝出來一樣」牢靠。
|
||
*
|
||
* 用法:
|
||
* node scripts/build-worker-artifacts.mjs [--check-only]
|
||
* --check-only:只驗證每個 worker 的 node_modules 是否已按 lockfile 裝好,不編譯。
|
||
*
|
||
* 輸出:.worker-builds/<name>/worker.mjs (+ *.wasm)、.worker-builds/manifest.json
|
||
*/
|
||
import esbuild from 'esbuild';
|
||
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
||
import { join, resolve, basename, relative } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { execSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
|
||
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
|
||
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
|
||
// 下面所有 esbuild 呼叫都用「相對 REPO」的相對路徑,輸出字串才會與絕對路徑無關。
|
||
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||
const OUT = join(REPO, '.worker-builds');
|
||
const CHECK_ONLY = process.argv.includes('--check-only');
|
||
|
||
/** 五顆 tier2 worker——與 arcrun-rag `installer/scripts/bundle-components.mjs`
|
||
* CORE_COMPONENTS 的 build 參數對齊(那邊的 esbuild 呼叫即將被本腳本的成品取代)。
|
||
* name 用 arcrun-rag 那邊的慣例(`arcrun-<kebab>`),方便安裝器直接對號。 */
|
||
const WORKERS = [
|
||
{ name: 'arcrun-cypher-executor', dir: 'cypher-executor', entry: 'src/index.ts', stripServices: true },
|
||
{ name: 'arcrun-kbdb', dir: 'kbdb', entry: 'src/index.ts' },
|
||
{ name: 'arcrun-http-request', dir: '.component-builds/http_request', entry: 'src/index.ts' },
|
||
{ name: 'arcrun-code', dir: 'registry/components/code', entry: 'index.ts' },
|
||
{ name: 'arcrun-mcp', dir: 'mcp', entry: 'src/index.ts' },
|
||
];
|
||
|
||
function sha256(buf) {
|
||
return createHash('sha256').update(buf).digest('hex');
|
||
}
|
||
|
||
/** 檢查一個 worker 目錄的 node_modules 是否已按它自己的 lockfile 裝好(踩坑②的閘)。 */
|
||
function checkNodeModules(dir) {
|
||
const abs = join(REPO, dir);
|
||
const hasPnpmLock = existsSync(join(abs, 'pnpm-lock.yaml'));
|
||
const hasNpmLock = existsSync(join(abs, 'package-lock.json'));
|
||
if (!hasPnpmLock && !hasNpmLock) {
|
||
return { ok: false, reason: `${dir} 沒有 pnpm-lock.yaml 也沒有 package-lock.json——依賴版本無法鎖定` };
|
||
}
|
||
if (!existsSync(join(abs, 'node_modules'))) {
|
||
const cmd = hasPnpmLock ? 'pnpm install --frozen-lockfile' : 'npm ci';
|
||
return { ok: false, reason: `${dir}/node_modules 不存在——先在該目錄跑:${cmd}` };
|
||
}
|
||
return { ok: true, via: hasPnpmLock ? 'pnpm (frozen)' : 'npm ci' };
|
||
}
|
||
|
||
/** 極簡 wrangler.toml 讀取(沿用 arcrun-rag build-bundles.mjs 同款邏輯,只抓需要的欄位)。 */
|
||
function readToml(tomlPath) {
|
||
const t = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf8') : '';
|
||
const spec = { kv: [], d1: [], vectorize: [], ai: null, vars: {}, compat_flags: [], compat_date: null };
|
||
const compatFlags = t.match(/compatibility_flags\s*=\s*\[([^\]]*)\]/);
|
||
if (compatFlags) spec.compat_flags = [...compatFlags[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
||
const compatDate = t.match(/compatibility_date\s*=\s*["']([^"']+)["']/);
|
||
if (compatDate) spec.compat_date = compatDate[1];
|
||
for (const m of t.matchAll(/\[\[kv_namespaces\]\][\s\S]*?binding\s*=\s*["']([^"']+)["']/g)) spec.kv.push(m[1]);
|
||
for (const m of t.matchAll(/\[\[d1_databases\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
|
||
const b = m[1].match(/binding\s*=\s*["']([^"']+)["']/);
|
||
const n = m[1].match(/database_name\s*=\s*["']([^"']+)["']/);
|
||
if (b) spec.d1.push({ binding: b[1], database_name: n ? n[1] : null });
|
||
}
|
||
for (const line of t.split('\n')) {
|
||
if (/^\s*\[\[vectorize\]\]/.test(line)) spec.vectorize.push(true);
|
||
}
|
||
if (/^\s*\[ai\]/m.test(t)) spec.ai = true;
|
||
const varsBlock = t.match(/\[vars\]([\s\S]*?)(?=\n\[|\n*$)/);
|
||
if (varsBlock) for (const m of varsBlock[1].matchAll(/^\s*([A-Z0-9_]+)\s*=\s*["']([^"']*)["']/gm)) spec.vars[m[1]] = m[2];
|
||
return spec;
|
||
}
|
||
|
||
/** esbuild plugin:.wasm import 攤平成同目錄檔名,記下要一起複製的 wasm part。 */
|
||
function wasmPlugin(wasmParts) {
|
||
return {
|
||
name: 'wasm-external',
|
||
setup(build) {
|
||
build.onResolve({ filter: /\.wasm$/ }, (args) => {
|
||
const abs = resolve(args.resolveDir, args.path);
|
||
const flat = basename(abs);
|
||
if (!wasmParts.find((w) => w.part === flat)) wasmParts.push({ part: flat, abs });
|
||
return { path: './' + flat, external: true };
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 每個 worker 自己的 source_commit:答到單顆目錄層級,不是整包一個欄位(Arcrun#80 的核心要求)。 */
|
||
function sourceCommitFor(dir) {
|
||
try {
|
||
const hash = execSync(`git log -1 --format=%H -- ${JSON.stringify(dir)}`, { cwd: REPO, encoding: 'utf8' }).trim();
|
||
return hash || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function repoHead() {
|
||
try {
|
||
const sha = execSync('git rev-parse HEAD', { cwd: REPO, encoding: 'utf8' }).trim();
|
||
// .worker-builds 是本腳本自己的輸出目錄——它在「寫出成品之前」永遠是 untracked,
|
||
// 拿它判斷「原始碼乾不乾淨」是假陽性(自己把自己判成髒)。排除掉才是真正的
|
||
// 「原始碼有沒有未 commit 的變更」。
|
||
const dirty = execSync('git status --porcelain -- . ":(exclude).worker-builds"', { cwd: REPO, encoding: 'utf8' }).trim();
|
||
return { sha, dirty: !!dirty };
|
||
} catch {
|
||
return { sha: null, dirty: null };
|
||
}
|
||
}
|
||
|
||
async function buildOne(w) {
|
||
const dirAbs = join(REPO, w.dir);
|
||
const entryAbs = join(dirAbs, w.entry);
|
||
if (!existsSync(entryAbs)) throw new Error(`entry 不存在: ${entryAbs}`);
|
||
const outDir = join(OUT, w.name);
|
||
mkdirSync(outDir, { recursive: true });
|
||
|
||
// 踩坑①的解法核心:entry 用「相對 REPO」的路徑,absWorkingDir 固定為 REPO——
|
||
// esbuild 內部產生的檔案邊界字串因此只依賴這個相對路徑,與這個 clone 實際被
|
||
// 放在磁碟的哪個絕對路徑無關。
|
||
const entryRel = relative(REPO, entryAbs);
|
||
|
||
const wasmParts = [];
|
||
const result = await esbuild.build({
|
||
absWorkingDir: REPO,
|
||
entryPoints: [entryRel],
|
||
bundle: true,
|
||
format: 'esm',
|
||
platform: 'browser',
|
||
target: 'es2022',
|
||
outfile: relative(REPO, join(outDir, 'worker.mjs')),
|
||
external: ['cloudflare:*', 'node:*'],
|
||
plugins: [wasmPlugin(wasmParts)],
|
||
logLevel: 'silent',
|
||
metafile: true,
|
||
});
|
||
|
||
const modules = [];
|
||
for (const wp of wasmParts) {
|
||
if (!existsSync(wp.abs)) throw new Error(`wasm 找不到: ${wp.abs}(該 worker 需先 build/vendored wasm)`);
|
||
copyFileSync(wp.abs, join(outDir, wp.part));
|
||
modules.push({ name: wp.part, type: 'application/wasm', file: `${w.name}/${wp.part}`, sha256: sha256(readFileSync(wp.abs)) });
|
||
}
|
||
|
||
const spec = readToml(join(dirAbs, 'wrangler.toml'));
|
||
const jsBuf = readFileSync(join(outDir, 'worker.mjs'));
|
||
return {
|
||
name: w.name,
|
||
source_dir: w.dir,
|
||
source_commit: sourceCommitFor(w.dir),
|
||
main_module: 'worker.mjs',
|
||
main_file: `${w.name}/worker.mjs`,
|
||
js_bytes: jsBuf.length,
|
||
content_sha256: sha256(jsBuf),
|
||
modules,
|
||
compat_date: spec.compat_date,
|
||
compat_flags: spec.compat_flags,
|
||
requires: {
|
||
kv: spec.kv,
|
||
d1: spec.d1,
|
||
vectorize: spec.vectorize.length,
|
||
ai: !!spec.ai,
|
||
vars: spec.vars,
|
||
},
|
||
stripped: w.stripServices ? { services: 13 } : undefined,
|
||
warnings: result.warnings.map((x) => x.text),
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`REPO = ${REPO}`);
|
||
|
||
const precheck = WORKERS.map((w) => ({ w, chk: checkNodeModules(w.dir) }));
|
||
const failed = precheck.filter((p) => !p.chk.ok);
|
||
if (failed.length) {
|
||
console.error('\n❌ 建置中止:以下 worker 尚未按 lockfile 裝好依賴(踩坑②的閘):\n');
|
||
for (const f of failed) console.error(` - ${f.chk.reason}`);
|
||
console.error('\n這是刻意設計:本腳本不自己跑 install,避免「install 方式不同 → 依賴版本不同 → 位元組不同」。');
|
||
process.exit(1);
|
||
}
|
||
console.log('✔ node_modules 檢查通過:');
|
||
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
|
||
|
||
if (CHECK_ONLY) {
|
||
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
|
||
return;
|
||
}
|
||
|
||
mkdirSync(OUT, { recursive: true });
|
||
for (const w of WORKERS) {
|
||
const d = join(OUT, w.name);
|
||
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
|
||
}
|
||
|
||
const head = repoHead();
|
||
const manifest = {
|
||
schema: 1,
|
||
built_for: 'arcrun-tier2-worker-artifacts',
|
||
generated_at: new Date().toISOString(),
|
||
repo_head: head.sha,
|
||
repo_dirty: head.dirty,
|
||
workers: [],
|
||
notes: [],
|
||
};
|
||
|
||
let failCount = 0;
|
||
for (const w of WORKERS) {
|
||
try {
|
||
const entry = await buildOne(w);
|
||
manifest.workers.push(entry);
|
||
const wasmNote = entry.modules.length ? ` +${entry.modules.length} wasm` : '';
|
||
console.log(`✔ ${w.name} js=${(entry.js_bytes / 1024).toFixed(0)}KB sha256=${entry.content_sha256.slice(0, 12)} source=${(entry.source_commit || '').slice(0, 8)}${wasmNote}`);
|
||
} catch (e) {
|
||
manifest.notes.push(`FAILED ${w.name}: ${e.message}`);
|
||
console.error(`✗ ${w.name}: ${e.message}`);
|
||
failCount++;
|
||
}
|
||
}
|
||
|
||
writeFileSync(join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||
console.log(`\nmanifest → ${join(OUT, 'manifest.json')} (${manifest.workers.length}/${WORKERS.length} built)`);
|
||
if (failCount > 0 || head.dirty) {
|
||
if (head.dirty) console.error('⚠️ 工作區不乾淨(有未 commit 的變更)——這份成品的 repo_head 標記不完全可信,僅供本地驗證用。');
|
||
if (failCount > 0) process.exit(1);
|
||
}
|
||
}
|
||
main().catch((e) => { console.error(e); process.exit(1); });
|