/** * arcrun component loader * * 解析優先序: * * 1. 內建零件(BUILTIN_COMPONENTS)— 純 JS,最快 * 2. 外部 URL(https://...)— 直接 fetch,n8n/MCP/任何 HTTP 服務 * 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(同帳號不走公網) * 6. KV recipe canonical_id → 從 RECIPES KV 讀取 recipe → fetch 外部 API * 7. 內建 API recipe(gmail/telegram/gsheets 等,寫死的 fallback) * 8. 找不到 → 報錯 */ import { BUILTIN_COMPONENTS } from './constants'; import { isComponentHash, isRecipeHash } from './hash'; import { resolveRecipe } from '../routes/recipes'; import type { Bindings, ComponentRunner, ServiceBinding } from '../types'; /** 邏輯零件 canonical_id → Service Binding key */ const LOGIC_BINDING_MAP: Record = { if_control: 'SVC_IF_CONTROL', switch: 'SVC_SWITCH', foreach_control: 'SVC_FOREACH_CONTROL', filter: 'SVC_FILTER', merge: 'SVC_MERGE', try_catch: 'SVC_TRY_CATCH', wait: 'SVC_WAIT', set: 'SVC_SET', array_ops: 'SVC_ARRAY_OPS', string_ops: 'SVC_STRING_OPS', number_ops: 'SVC_NUMBER_OPS', date_ops: 'SVC_DATE_OPS', validate_json: 'SVC_VALIDATE_JSON', ai_transform_compile: 'SVC_AI_TRANSFORM_COMPILE', ai_transform_run: 'SVC_AI_TRANSFORM_RUN', }; export function createComponentLoader(env: Bindings) { return async (componentId: string): Promise => { // 1. 內建零件(純 JS,最優先) const builtin = BUILTIN_COMPONENTS.get(componentId); if (builtin) return builtin; // 2. 外部 URL if (componentId.startsWith('http://') || componentId.startsWith('https://')) { return makeHttpRunner(componentId); } // 3. cmp_hash → 查 WEBHOOKS KV idx → canonical_id → 邏輯 Worker if (isComponentHash(componentId)) { const canonicalId = await env.WEBHOOKS.get(`idx:${componentId}`); if (canonicalId) { return makeLogicRunner(canonicalId, env); } throw new Error(`找不到零件 hash "${componentId}",請確認已透過 acr push 上傳`); } // 4. rec_hash → 查 RECIPES KV idx → recipe 執行 if (isRecipeHash(componentId)) { const recipe = await resolveRecipe(componentId, env.RECIPES); if (recipe) return makeRecipeRunner(recipe); throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`); } // 5. 邏輯零件 canonical_id → Service Binding const logicRunner = makeLogicRunner(componentId, env); if (logicRunner) return logicRunner; // 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; // 8. 找不到 throw new Error( `找不到零件 "${componentId}"。\n` + `邏輯零件:${Object.keys(LOGIC_BINDING_MAP).join(', ')}\n` + `或傳入外部 URL(https://...)、recipe hash(rec_xxxxxxxx)、零件 hash(cmp_xxxxxxxx)` ); }; } // ── 執行器工廠 ──────────────────────────────────────────────────────────────── function makeHttpRunner(url: string): ComponentRunner { return async (ctx: unknown) => { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ctx), }); if (!res.ok) { const text = await res.text(); return { success: false, status: res.status, error: text.slice(0, 200) }; } try { return await res.json(); } catch { return { success: true, data: await res.text() }; } }; } function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner | null { const bindingKey = LOGIC_BINDING_MAP[canonicalId]; if (!bindingKey) return null; const svc = env[bindingKey] as ServiceBinding | undefined; if (svc) { return async (ctx: unknown) => { const res = await svc.fetch(new Request('https://component/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ctx), })); if (!res.ok) { const text = await res.text(); return { success: false, error: `${canonicalId} 回傳 ${res.status}: ${text.slice(0, 200)}` }; } try { return await res.json(); } catch { return { success: false, error: `${canonicalId} 回傳非 JSON` }; } }; } // Service Binding 未配置時 fallback 到公網(自製零件 or 開發環境) const fallbackUrl = `https://${canonicalId.replace(/_/g, '-')}.arcrun.dev`; return makeHttpRunner(fallbackUrl); } function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner { return async (ctx: unknown) => { const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record : {}; // 模板替換:把 {{key}} 換成 ctx 裡的值 const interpolate = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => String(ctxObj[k] ?? '')); const method = (recipe.method ?? 'POST').toUpperCase(); const headers: Record = { 'Content-Type': 'application/json' }; for (const [k, v] of Object.entries(recipe.headers ?? {})) { headers[k] = interpolate(v); } // body:把 recipe.body 裡的 {{key}} 都換掉 let bodyStr: string | undefined; if (recipe.body) { bodyStr = interpolate(JSON.stringify(recipe.body)); } else if (method !== 'GET') { bodyStr = JSON.stringify(ctxObj); } const res = await fetch(interpolate(recipe.endpoint), { method, headers, body: bodyStr, }); const data = await res.json().catch(() => res.text()); return { success: res.ok, status: res.status, data }; }; } // ── 內建 API recipe(不存在 KV,直接寫死)──────────────────────────────────── const BUILTIN_API_RECIPES: Record = { http_request: async (ctx) => { const c = ctx as Record; 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) ?? {}; 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 }; }, gmail: async (ctx) => { const c = ctx as Record; 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() }; }, telegram: async (ctx) => { const c = ctx as Record; 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() }; }, line_notify: async (ctx) => { const c = ctx as Record; 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; 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 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 }), };