Files
Arcrun/cypher-executor/src/lib/component-loader.ts
T
Leo 60d3e41905 feat: component hash IDs + dynamic KV recipe system
Hash system:
- cmp_xxxxxxxx: stable ID for logic components (SHA-256 of canonical_id)
- rec_xxxxxxxx: stable ID for API recipe components
- Pre-seeded 15 cmp_ + 6 rec_ hash indexes in KV

RECIPES KV (id: 9cf9db905c6241f78503199e58b2ffe0):
- POST/GET/DELETE /recipes — CRUD for API recipe definitions
- recipe stored as: recipe:{canonical_id} + idx:{rec_hash}
- template interpolation: {{key}} replaced from context

component-loader resolution order:
  builtin → external URL → cmp_ hash → rec_ hash →
  logic canonical_id → KV recipe → builtin API fallback → error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:36:51 +08:00

240 lines
9.8 KiB
TypeScript
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.
/**
* arcrun component loader
*
* 解析優先序:
*
* 1. 內建零件(BUILTIN_COMPONENTS)— 純 JS,最快
* 2. 外部 URLhttps://...)— 直接 fetchn8n/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 recipegmail/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<string, keyof Bindings> = {
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<ComponentRunner> => {
// 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` +
`或傳入外部 URLhttps://...)、recipe hashrec_xxxxxxxx)、零件 hashcmp_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<string, unknown> : {};
// 模板替換:把 {{key}} 換成 ctx 裡的值
const interpolate = (s: string) =>
s.replace(/\{\{(\w+)\}\}/g, (_, k) => String(ctxObj[k] ?? ''));
const method = (recipe.method ?? 'POST').toUpperCase();
const headers: Record<string, string> = { '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<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 };
},
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() };
},
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() };
},
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 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 }),
};