Files
Arcrun/scripts/build-ui-worker.mjs
T
uncle6me-web 45169ab25e feat(build): 一個實例會用到的每一顆 worker 都在 Arcrun 編(D91/Arcrun#125)
官方編譯點從 5 顆擴到 23 顆,並把 portal 前端的產生器從 arcrun-rag 搬回來。

為什麼不是「順手多編幾顆」:
- 只編 5 顆 ⇒ 下游 bundle 裡就只有 5 顆 ⇒「零件用到才下載」沒有貨可以下載。
  而 auth_static_key 是每一支產品工作流解 {{credential.X}} 都會打的那一顆
  ⇒ 走網頁安裝器裝出來的實例,工作流一跑就 500(Arcrun#124 的 error code: 1042)。
- portal 前端(arcrun-rag-ui)當時仍由 arcrun-rag 自己拼裝原始碼
  ⇒ 使用者拿到的那顆 worker,沒有任何 Arcrun commit 說得出它的來源。
  leo:「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build,這就是 arcrun 的專屬工作。」

做法:
- 零件 worker 改用**掃描** .component-builds/(不是手寫清單——手寫清單就是
  arcrun-rag#27/D48 那個病的形狀),worker 名一律讀該零件自己的 wrangler.toml。
- 可出貨的判準=**部署位元組在版控裡**。.gitignore 明文排除的三顆
  (claude_api/km_writer/kbdb_upsert_block,DECISIONS §1「錯做成零件」)
  因此自動落在公庫外,而且是**寫進 manifest.excluded 的**,不是安靜跳過。
- 輸出目錄改成每次只留這一輪該有的,清掉舊成品(拔掉棘輪)。
- UI 產生器原樣搬過來,兩道閘(世代閘、前端 JS 語法閘)一起搬——
  它們驗的是本 repo 的 console-ui/public,本來就該長在來源這一側。

實測(本機 worktree,依 lockfile 重裝依賴後):
- 23/23 編成功;四顆既有引擎成品**位元組完全重現** committed 值
  (cypher 8e6487478bc8/kbdb f15e16cca6ce/code 751634a3fc9a/mcp 9bc8dffb34d0/
   http-request cdd97364f277)⇒ 這次擴充沒有動到既有零件。
- arcrun-rag-ui 的產出與搬家前逐位元比對,**只差第一行註解**
  (那行必須改:它原本指向一支不再產生它的腳本)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:59:59 +08:00

209 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* build-ui-worker.mjs — 把 `console-ui/public` 內嵌成一顆單檔 worker`arcrun-rag-ui`)。
*
* 🔴 2026-08-15D91Arcrun#125):這支是**從 arcrun-rag 搬過來的**。
* leo 原話:「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build
* **這就是 arcrun 的專屬工作**。你告訴我要把 cypher 搬到 arcrun,我說好,
* 結果搞到現在還用違反的方式。」
*
* 實況(2026-08-14 查):五顆 tier2 worker 早就改成「向 Arcrun 取用成品」了
* `build-worker-artifacts.mjs` → `.worker-builds/`),**只有這顆 UI 沒搬**——
* arcrun-rag 的 `installer/scripts/build-ui-bundle.mjs` 仍然自己讀本 repo 的
* `console-ui/public`、自己拼裝出 worker 原始碼。於是:
* · 用戶拿到的 portal 前端,其位元組沒有任何一個 Arcrun commit 說得出來源
* · 「成品只有一個產地」這句話對六顆零件裡的五顆成立,對第六顆不成立
* ⇒ 規則不是被誰違反,是**它從來沒有被機械驗證過**(同 D54/D64/D90 的形狀)。
*
* 搬過來之後,UI 與其他零件走同一條路:
* 產地(本 repo)→ `.worker-builds/arcrun-rag-ui/worker.mjs` manifest 指紋
* → arcrun-rag 只做「複製 + 算版本」,不再有任何一行產生 worker 原始碼。
*
* ⚠️ 內嵌邏輯與兩道閘(世代閘、前端 JS 語法閘)**原樣搬過來,一個字沒改**——
* 它們驗的是本 repo 的 `console-ui/public`,本來就該長在來源這一側。
* 唯一改動:產出物開頭那行註解改成指向本檔(不留「描述不存在機制的註解」)。
*
* 對外只有一個 API`buildUiWorker({ uiDir })` → `{ source, fingerprint, fileCount }`。
* 寫檔與 manifest 由 `build-worker-artifacts.mjs` 統一處理(產物格式與其他零件一致)。
*/
import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
import { createHash } from 'node:crypto';
/**
* @param {{ uiDir: string }} opts uiDir`console-ui/public` 的絕對路徑
* @returns {Promise<{ source: string, fingerprint: string, fileCount: number }>}
*/
export async function buildUiWorker({ uiDir }) {
const UI = uiDir;
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
const TEXT_EXT = new Set(['.html', '.js', '.mjs', '.css', '.json', '.svg']);
// config.js 由 worker 動態產生(apiBase 注入),不吃靜態版
const SKIP = new Set(['/config.js']);
// ⚠️ 一定要 sort():指紋是 JSON.stringify(files) 算的,鍵的順序=目錄列舉順序。
// 不排序的話同樣的內容在不同機器會得到不同指紋(B 版就漏了這一步)。
function walk(dir, base, out) {
for (const name of readdirSync(dir).sort()) {
const p = join(dir, name);
if (statSync(p).isDirectory()) { walk(p, base, out); continue; }
const key = '/' + relative(base, p).split('\\').join('/');
if (SKIP.has(key)) continue;
const ext = extname(name);
const type = TYPES[ext];
if (!type) continue; // 未知型別不進 bundle
if (TEXT_EXT.has(ext)) out[key] = { type, b64: false, data: readFileSync(p, 'utf8') };
else out[key] = { type, b64: true, data: readFileSync(p).toString('base64') };
}
}
// ── 閘① t160 世代閘(B 版)─────────────────────────────────────────────
// 打包前驗 portal 頁指紋——舊世代(缺「不需要人工新增」文案/含人工建庫「登記新庫」表單)
// 直接拒打包。病史:t159 重打包吃了停在舊世代的分支 public/,把 leo 實例的 UI 打回
// 被淘汰的人工登記典範。
// 🔴 2026-08-02 修誤判:原本直接對整份原始碼 grep「登記新庫」,但**現行世代的 HTML
// 註解裡就寫著「07-27 leo:拿掉『登記新庫』…」**——那是「已經拿掉了」的紀錄,
// 是新世代的證據,卻被當成舊世代特徵 ⇒ 閘把對的東西擋下來。
// 正解:**先剝掉註解再驗**,只看用戶真的會看到的 UI 內容。
{
const portalRaw = readFileSync(join(UI, 'portal', 'index.html'), 'utf8');
const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
console.error('✘ 世代閘:--ui 指向的 public 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕打包舊 UI。');
console.error(` 剝註解後:不需要人工新增=${portal.includes('不需要人工新增')}/登記新庫=${portal.includes('登記新庫')}`);
process.exit(1);
}
}
const files = {};
walk(UI, UI, files);
if (!files['/portal/index.html']) throw new Error(`/portal/index.html 不在 ${UI} — 路徑錯了?`);
// ── 閘② t131 前端 JS 語法閘(A 版)────────────────────────────────────
// 2026-07-29 事故:誤刪一個 `})();` ⇒ portal 白畫面,驗收沒抓到——
// 因為測試只測後端、grep 只看字串,**沒有人驗過前端 JS 語法**。
// 打包前用 node --check 驗每個 HTML 內嵌 script,語法錯就拒絕產出 bundle。
{
const { execFileSync } = await import('node:child_process');
const { mkdtempSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const tmp = mkdtempSync(join(tmpdir(), 'uicheck-'));
for (const [name, f] of Object.entries(files)) {
if (!name.endsWith('.html') || f.b64) continue;
const blocks = [...String(f.data).matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)];
blocks.forEach((m, i) => {
const jsPath = join(tmp, `${name.replace(/[^\w]/g, '_')}_${i}.js`);
writeFileSync(jsPath, m[1]);
try {
execFileSync(process.execPath, ['--check', jsPath], { stdio: 'pipe' });
} catch (e) {
const msg = String(e.stderr || e.message).split('\n').slice(0, 6).join('\n');
throw new Error(`❌ ${name} 的第 ${i + 1} 個 <script> 有語法錯,拒絕打包(會導致白畫面):\n${msg}`);
}
});
}
console.log('✅ 前端 JS 語法閘通過');
}
// 🔴 2026-08-02leo 實撞:「一樣從 install 安裝,為什麼會裝到錯誤版本?」)
// UI 內容指紋——**UI 過去沒有自己的版本號**,安裝器只能猜特徵
// t168 猜「/favicon.svg 開頭是不是 <svg」),而那只能分辨「有沒有 favicon 路由」
// 這一個世代差 ⇒ 舊 UI 早就有 favicon 路由,於是被判定「已是新版」
// ⇒ **整批跳過、UI 永遠不重推**,用戶重裝幾次都拿到舊前端。
// 正解不是再想一個新特徵去猜(下個世代還會再猜錯),是**讓 UI 自己報版本**。
const uiFingerprint = createHash('sha256').update(JSON.stringify(files)).digest('hex');
const worker = `// arcrun-rag-ui — 用戶實例的 GUIconsole-ui/public 內嵌;由 Arcrun scripts/build-ui-worker.mjs 產生,勿手改)
const FILES = ${JSON.stringify(files)};
const UI_FINGERPRINT = ${JSON.stringify(uiFingerprint)};
function b64ToBytes(b64) { const bin = atob(b64); const a = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) a[i] = bin.charCodeAt(i); return a; }
export default {
async fetch(request, env) {
const url = new URL(request.url);
let p = url.pathname;
// 🔴 UI 自報版本(安裝器據此判斷要不要重推,取代「猜 favicon 特徵」)。
// 比照 cypher 的 /health。舊世代的 UI 沒有這條路由 ⇒ SPA fallback 回 HTML
// ⇒ 安裝器解析 JSON 失敗=判定為舊版=重推。**天然向後相容,不需額外世代判斷。**
// bundle_version 由安裝器以 ARCRUN_BUNDLE_VERSION 注入(cypher /health 同一個值);
// 沒注入就回空字串,安裝器那端把空值一律當「舊的」處理。
if (p === '/__version') {
// 🔴 2026-08-08:多報 worker_subdomain / config_ok 兩格。
// 這條路由是**安裝器唯一會去問實例的地方**probeInstanceStale),
// 以前它只報版本 ⇒「版本對、但 apiBase 空」的實例在安裝器眼中是「已是最新」
// ⇒ 永遠不會被重推=壞掉的實例自己好不了。報了之後,安裝器把
// config_ok:false 一律當成 stale ⇒ **下一次安裝/更新就自動治好它**。
return new Response(JSON.stringify({
ok: true,
ui_fingerprint: UI_FINGERPRINT,
bundle_version: env.ARCRUN_BUNDLE_VERSION || '',
worker_subdomain: env.WORKER_SUBDOMAIN || '',
config_ok: Boolean(env.WORKER_SUBDOMAIN),
}), {
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
});
}
// config.js 動態產生:apiBase 指向同帳號的 cypherWORKER_SUBDOMAIN 由安裝器注入)
//
// 🔴 2026-08-08 事故(leo 的 youlin 實例整頁不能用,頂端紅字「設定檔沒載入」):
// 有人用 wrangler deploy 手動補這顆 worker ⇒ 繞過安裝器的變數注入
// ⇒ WORKER_SUBDOMAIN 空 ⇒ 這裡**安靜地**退成 apiBase:""(而且回 HTTP 200!)
// ⇒ 前端連不到自己的後端,但**部署是「成功」的**:worker 活著、curl 200、
// /__version 版本也對——只有真人打開瀏覽器才看得出壞了。
// 正解不是「退一個合理的預設值」(沒有合理預設,猜錯更慘),
// 是**大聲失敗**:回 500+專用標頭,讓機器檢查一眼看穿。
// 瀏覽器不執行非 2xx 的 <script>,所以用戶端行為與今天一致
// (portal 照樣顯示「設定檔沒載入」紅字),差別是**機器也看得見了**。
// no-store:這頁被邊緣快取過就會「修好了還是壞的」(08-08 實撞,害人誤判修復失敗)。
if (p === '/config.js') {
const sub = env.WORKER_SUBDOMAIN || '';
if (!sub) {
return new Response(CONFIG_MISSING_BODY, {
status: 500,
headers: {
'content-type': TYPES_JS,
'cache-control': 'no-store',
'x-arcrun-config-error': 'WORKER_SUBDOMAIN-missing',
},
});
}
const apiBase = \`https://arcrun-cypher-executor.\${sub}.workers.dev\`;
return new Response(\`window.ARCRUN_CONFIG = { apiBase: \${JSON.stringify(apiBase)} };\`, {
headers: { 'content-type': TYPES_JS, 'cache-control': 'no-store' },
});
}
if (p.endsWith('/')) p += 'index.html';
let f = FILES[p] || FILES[p + '/index.html'] || FILES[p + '.html'];
if (!f && !p.includes('.')) f = FILES[p + '/index.html'];
if (!f) { p = '/portal/index.html'; f = FILES[p]; } // SPA fallback→portal
if (!f) return new Response('not found', { status: 404 });
const body = f.b64 ? b64ToBytes(f.data) : f.data;
return new Response(body, { headers: { 'content-type': f.type, 'cache-control': 'no-cache' } });
},
};
const TYPES_JS = 'application/javascript; charset=utf-8';
const CONFIG_MISSING_BODY = [
'/* ============================================================',
' arcrun-rag-uiWORKER_SUBDOMAIN 這個變數沒有被注入。',
' 算不出 apiBase = 前端不知道自己的後端在哪 = 整個 portal 不能用。',
'',
' 成因幾乎一定是:這顆 worker 被手動 wrangler deploy 部署,繞過了注入。',
' 修法(擇一):',
' 1. 重跑一次安裝器(它會注入,且會把這顆判定為 stale 而重推)',
' 2. node installer/scripts/deploy-ui.mjs --subdomain <你的 workers.dev 子網域>',
' ============================================================ */',
'throw new Error("arcrun-rag-ui: WORKER_SUBDOMAIN 未注入,apiBase 無法產生");',
].join('\\n');
`;
return { source: worker, fingerprint: uiFingerprint, fileCount: Object.keys(files).length };
}