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:
@@ -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 recipe(gmail/telegram/gsheets 等,寫死的 fallback)
|
||||
* 8. 找不到 → 報錯
|
||||
* 7. 內建 API recipe(gmail/telegram/gsheets 等,寫死的 fallback — Phase 3 將刪除)
|
||||
* 8. WASM HTTP runner(auth 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 runner:canonical_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 primitives(Phase 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 };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user