60d3e41905
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>
240 lines
9.8 KiB
TypeScript
240 lines
9.8 KiB
TypeScript
/**
|
||
* 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<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` +
|
||
`或傳入外部 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<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 }),
|
||
};
|