feat(components): move 6 API components to independent WASM Workers

Deploys gmail, telegram, line_notify, google_sheets, http_request, cron
as independent Cloudflare Workers at {name-kebab}.arcrun.dev. Each
wraps the TinyGo WASM from registry/components/{name}/main.go via
wasi-shim cross-import (Method A).

component-loader no longer carries BUILTIN_API_RECIPES — those
hardcoded gmail.googleapis.com / api.telegram.org / sheets / line-notify
endpoints all lived in TS, violating "all business logic in WASM".
Resolution chain now routes the 6 canonical IDs straight to their
{name}.arcrun.dev Worker URLs via WASM_HTTP_RUNNER_IDS.

Per .agents/specs/arcrun/credential-primitives-wasm Phase 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:36:06 +08:00
parent 8c14562a2f
commit 6a3219e51b
31 changed files with 6231 additions and 75 deletions
+85 -75
View File
@@ -8,16 +8,49 @@
* 3. cmp_xxxxxxxx hash → 查 registry KV idx → canonical_id → 邏輯 Worker
* 4. rec_xxxxxxxx hash → 查 RECIPES KV idx → canonical_id → KV recipe 執行
* 5. 邏輯零件 canonical_id → Service Binding(同帳號不走公網)
* 5.5. Auth recipe(平台預建)→ Auth Recipe Runner
* 6. KV recipe canonical_id → 從 RECIPES KV 讀取 recipe → fetch 外部 API
* 7. 內建 API recipegmail/telegram/gsheets 等,寫死的 fallback
* 8. 找不到 → 報錯
* 7. 內建 API recipegmail/telegram/gsheets 等,寫死的 fallback — Phase 3 將刪除
* 8. WASM HTTP runnerauth primitive / API 零件 → 獨立 Worker URL
* Phase 3 刪掉 7 之後,6 個 API 零件也會落到這裡;目前優先保留 7 以免 Worker 未部署造成 404。
* 9. 找不到 → 報錯
*/
import { BUILTIN_COMPONENTS } from './constants';
import { isComponentHash, isRecipeHash } from './hash';
import { resolveRecipe } from '../routes/recipes';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
/**
* WASM HTTP runnercanonical_id → 對應獨立 Worker URL。
*
* 所有 WASM 零件(auth primitive / API 零件 / 未來用戶自製)都是獨立部署的 Worker,
* 以 `{canonical-id-kebab}.arcrun.dev` 為 URL 慣例。cypher-executor 不做 WASM
* instantiate,只做 HTTP fetch。這層是 API 零件(及 auth primitive)的唯一入口。
*
* R2 動態注入 WASM 路徑作廢(CF workerd 不支援以 R2 物件臨時 instantiate)。
*/
const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = new Set([
// API 零件(對應 registry/components/ 下的 TinyGo WASM
'http_request',
'gmail',
'telegram',
'line_notify',
'google_sheets',
'cron',
// Auth primitivesPhase 1-4 將逐步部署對應 Worker
'auth_static_key',
'auth_service_account',
'auth_oauth2',
'auth_mtls',
]);
/** canonical_id → 獨立 Worker URL(慣例:snake_case → kebab-case + .arcrun.dev */
export function wasmWorkerUrl(canonicalId: string): string {
return `https://${canonicalId.replace(/_/g, '-')}.arcrun.dev`;
}
/** 邏輯零件 canonical_id → Service Binding key */
const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
if_control: 'SVC_IF_CONTROL',
@@ -53,7 +86,8 @@ export function createComponentLoader(env: Bindings) {
if (isComponentHash(componentId)) {
const canonicalId = await env.WEBHOOKS.get(`idx:${componentId}`);
if (canonicalId) {
return makeLogicRunner(canonicalId, env);
const runner = makeLogicRunner(canonicalId, env);
if (runner) return runner;
}
throw new Error(`找不到零件 hash "${componentId}",請確認已透過 acr push 上傳`);
}
@@ -69,13 +103,21 @@ export function createComponentLoader(env: Bindings) {
const logicRunner = makeLogicRunner(componentId, env);
if (logicRunner) return logicRunner;
// 5.5 Auth recipe(平台預建,auth_recipe:{service} in RECIPES KV
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (authRecipe) return makeAuthRecipeRunner(authRecipe);
// 6. KV recipe(動態,用戶 push 的)
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
if (kvRecipe) return makeRecipeRunner(kvRecipe);
// 7. 內建 API recipe(寫死的 fallback
const builtinRecipe = BUILTIN_API_RECIPES[componentId];
if (builtinRecipe) return builtinRecipe;
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
// Phase 3 後 6 個 API 零件(http_request / gmail / telegram / line_notify /
// google_sheets / cron)與 4 個 auth primitive 都從這裡走。
// 對應 Worker 部署於 `{canonical-id-kebab}.arcrun.dev`(rule 03)。
if (WASM_HTTP_RUNNER_IDS.has(componentId)) {
return makeHttpRunner(wasmWorkerUrl(componentId));
}
// 8. 找不到
throw new Error(
@@ -163,77 +205,45 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
};
}
// ── 內建 API recipe(不存在 KV,直接寫死)────────────────────────────────────
// ── Auth Recipe Runner ────────────────────────────────────────────────────────
//
// credential-injector 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// 這裡只需要讀取這些欄位,合併進 fetch,再清除 _auth_* 不傳給下游。
const BUILTIN_API_RECIPES: Record<string, ComponentRunner> = {
http_request: async (ctx) => {
const c = ctx as Record<string, unknown>;
const url = c.url as string;
if (!url) return { success: false, error: 'url 必填' };
const method = ((c.method as string) ?? 'GET').toUpperCase();
const headers = (c.headers as Record<string, string>) ?? {};
const body = c.body !== undefined ? JSON.stringify(c.body) : undefined;
const res = await fetch(url, { method, headers, body });
const text = await res.text();
let data: unknown = text;
try { data = JSON.parse(text); } catch { /* keep as text */ }
return { success: res.ok, status: res.status, data };
},
function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
gmail: async (ctx) => {
const c = ctx as Record<string, string>;
if (!c.access_token) return { success: false, error: 'access_token 必填(由 credentials 注入)' };
if (!c.to || !c.subject || !c.body) return { success: false, error: 'to, subject, body 必填' };
const message = `To: ${c.to}\r\nSubject: ${c.subject}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${c.body}`;
const encoded = btoa(unescape(encodeURIComponent(message)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
method: 'POST',
headers: { 'Authorization': `Bearer ${c.access_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: encoded }),
});
return { success: res.ok, data: await res.json() };
},
const authHeaders = (ctxObj._auth_headers as Record<string, string>) ?? {};
const authQuery = (ctxObj._auth_query as Record<string, string>) ?? {};
telegram: async (ctx) => {
const c = ctx as Record<string, string>;
if (!c.bot_token) return { success: false, error: 'bot_token 必填(由 credentials 注入)' };
const res = await fetch(`https://api.telegram.org/bot${c.bot_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: c.chat_id, text: c.text }),
});
return { success: res.ok, data: await res.json() };
},
// _path 讓呼叫者指定 endpoint 後綴(e.g. /pages, /messages),可選
const path = typeof ctxObj._path === 'string' ? ctxObj._path : '';
const method = ((ctxObj.method as string) ?? 'POST').toUpperCase();
line_notify: async (ctx) => {
const c = ctx as Record<string, string>;
if (!c.token) return { success: false, error: 'token 必填(由 credentials 注入)' };
const res = await fetch('https://notify-api.line.me/api/notify', {
method: 'POST',
headers: { 'Authorization': `Bearer ${c.token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ message: c.message }).toString(),
});
return { success: res.ok, data: await res.json() };
},
google_sheets: async (ctx) => {
const c = ctx as Record<string, unknown>;
if (!c.access_token) return { success: false, error: 'access_token 必填(由 credentials 注入)' };
const hdrs = { 'Authorization': `Bearer ${c.access_token}`, 'Content-Type': 'application/json' };
if ((c.operation ?? 'read') === 'read') {
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${c.spreadsheet_id}/values/${c.range}`,
{ headers: hdrs }
);
return { success: res.ok, data: await res.json() };
const url = new URL(recipe.base_url.replace(/\/$/, '') + path);
for (const [k, v] of Object.entries(authQuery)) {
url.searchParams.set(k, v);
}
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${c.spreadsheet_id}/values/${c.range}:append?valueInputOption=USER_ENTERED`,
{ method: 'POST', headers: hdrs, body: JSON.stringify({ values: c.values }) }
);
return { success: res.ok, data: await res.json() };
},
cron: async (ctx) => ({ success: true, data: ctx }),
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...authHeaders,
};
// body:剔除所有 _ 前綴的內部欄位,以及 method
const bodyObj = Object.fromEntries(
Object.entries(ctxObj).filter(([k]) => !k.startsWith('_') && k !== 'method'),
);
const res = await fetch(url.toString(), {
method,
headers,
body: method !== 'GET' ? JSON.stringify(bodyObj) : undefined,
});
const data = await res.json().catch(() => res.text());
return { success: res.ok, status: res.status, data };
};
}