#!/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//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-`),方便安裝器直接對號。 */ 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); });