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>
This commit is contained in:
2026-04-16 18:36:51 +08:00
parent d8e6964088
commit 60d3e41905
6 changed files with 345 additions and 146 deletions
+2
View File
@@ -11,6 +11,7 @@ import { webhooksRouter } from './routes/webhooks';
import { webhooksCrudRouter } from './routes/webhooks-crud';
import { webhooksListRouter } from './routes/webhooks-list';
import { registerRouter } from './routes/register';
import { recipesRouter } from './routes/recipes';
const app = new Hono<{ Bindings: Bindings }>();
@@ -27,6 +28,7 @@ app.route('/', webhooksRouter);
app.route('/', webhooksCrudRouter);
app.route('/', webhooksListRouter);
app.route('/', registerRouter);
app.route('/', recipesRouter);
// Worker 導出
export default app;
+178 -146
View File
@@ -1,28 +1,25 @@
/**
* arcrun component loader
*
* 三種執行模式
* 解析優先序
*
* 1. 邏輯零件(category=logic
* → fetch POST https://{name-with-dashes}.arcrun.dev
* 每個邏輯零件是獨立 CF Worker,有 WASM 靜態 bundle
*
* 2. API recipe 零件(category=api
* → 從 CREDENTIALS_KV 讀取 recipefetch 外部 API
* 不需要獨立 Worker,整個執行在 cypher-executor 裡完成
*
* 3. 外部 URL 零件(componentId 以 http:// 或 https:// 開頭)
* → 直接 fetch,可以是 n8n webhook、MCP endpoint 等任何 HTTP 服務
*
* 4. 內建零件(BUILTIN_COMPONENTS
* → 純 JS 函數,不需要網路呼叫
* 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 讀取 recipefetch 外部 API
* 7. 內建 API recipegmail/telegram/gsheets 等,寫死的 fallback
* 8. 找不到 → 報錯
*/
import { BUILTIN_COMPONENTS } from './constants';
import type { Bindings, ComponentRunner } from '../types';
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 import('../types').Bindings> = {
const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
if_control: 'SVC_IF_CONTROL',
switch: 'SVC_SWITCH',
foreach_control: 'SVC_FOREACH_CONTROL',
@@ -40,14 +37,142 @@ const LOGIC_BINDING_MAP: Record<string, keyof import('../types').Bindings> = {
ai_transform_run: 'SVC_AI_TRANSFORM_RUN',
};
/** API 零件 canonical_id → recipeendpoint + 組裝邏輯)*/
const API_RECIPES: Record<string, (ctx: Record<string, unknown>) => Promise<unknown>> = {
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 url = ctx.url as string;
const method = (ctx.method as string ?? 'GET').toUpperCase();
const headers = (ctx.headers as Record<string, string>) ?? {};
const body = ctx.body !== undefined ? JSON.stringify(ctx.body) : undefined;
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;
@@ -56,152 +181,59 @@ const API_RECIPES: Record<string, (ctx: Record<string, unknown>) => Promise<unkn
},
gmail: async (ctx) => {
const { to, subject, body, access_token } = ctx as Record<string, string>;
if (!access_token) return { success: false, error: 'access_token 必填(由 credentials 注入)' };
if (!to || !subject || !body) return { success: false, error: 'to, subject, body 必填' };
// Build RFC 2822 message + base64url encode
const message = `To: ${to}\r\nSubject: ${subject}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body}`;
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 ${access_token}`, 'Content-Type': 'application/json' },
headers: { 'Authorization': `Bearer ${c.access_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: encoded }),
});
const data = await res.json();
return { success: res.ok, data };
return { success: res.ok, data: await res.json() };
},
telegram: async (ctx) => {
const { bot_token, chat_id, text } = ctx as Record<string, string>;
if (!bot_token) return { success: false, error: 'bot_token 必填(由 credentials 注入)' };
const res = await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, {
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, text }),
body: JSON.stringify({ chat_id: c.chat_id, text: c.text }),
});
const data = await res.json();
return { success: res.ok, data };
return { success: res.ok, data: await res.json() };
},
line_notify: async (ctx) => {
const { token, message } = ctx as Record<string, string>;
if (!token) return { success: false, error: 'token 必填(由 credentials 注入)' };
const form = new URLSearchParams({ message });
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 ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
headers: { 'Authorization': `Bearer ${c.token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ message: c.message }).toString(),
});
const data = await res.json();
return { success: res.ok, data };
return { success: res.ok, data: await res.json() };
},
google_sheets: async (ctx) => {
const { access_token, spreadsheet_id, range, values, operation } = ctx as Record<string, unknown>;
if (!access_token) return { success: false, error: 'access_token 必填(由 credentials 注入)' };
const headers = { 'Authorization': `Bearer ${access_token}`, 'Content-Type': 'application/json' };
const op = (operation as string) ?? 'read';
if (op === 'read') {
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/${spreadsheet_id}/values/${range}`,
{ headers }
`https://sheets.googleapis.com/v4/spreadsheets/${c.spreadsheet_id}/values/${c.range}`,
{ headers: hdrs }
);
const data = await res.json();
return { success: res.ok, data };
} else {
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheet_id}/values/${range}:append?valueInputOption=USER_ENTERED`,
{ method: 'POST', headers, body: JSON.stringify({ values }) }
);
const data = await res.json();
return { success: res.ok, data };
return { success: res.ok, data: await res.json() };
}
},
cron: async (ctx) => {
// cron 是觸發源,在 workflow 執行時已被觸發,直接 passthrough
return { success: true, data: ctx };
},
ai_transform_compile: async (ctx) => {
// fallback — 通常由 logic Worker 處理,這裡是保險
return { success: true, data: ctx };
},
ai_transform_run: async (ctx) => {
return { success: true, data: ctx };
},
};
export function createComponentLoader(env: Bindings) {
return async (componentId: string): Promise<ComponentRunner> => {
// 1. 內建零件(純 JS,最優先)
const builtin = BUILTIN_COMPONENTS.get(componentId);
if (builtin) return builtin;
// 2. 外部 URLcomponentId 直接是 http/https URL
if (componentId.startsWith('http://') || componentId.startsWith('https://')) {
return async (ctx: unknown) => {
const res = await fetch(componentId, {
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 };
}
try { return await res.json(); }
catch { return { success: true, data: await res.text() }; }
};
}
// 3. 邏輯零件 → Service BindingCF Workers 直接呼叫,不走公網)
const bindingKey = LOGIC_BINDING_MAP[componentId];
if (bindingKey) {
const svc = env[bindingKey] as import('../types').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: `${componentId} 回傳 ${res.status}: ${text.slice(0, 200)}` };
}
try { return await res.json(); }
catch { return { success: false, error: `${componentId} 回傳非 JSON` }; }
};
}
// Service Binding 未配置時 fallback 到公網 URL(開發環境)
const fallbackUrl = `https://${componentId.replace(/_/g, '-')}.arcrun.dev`;
return async (ctx: unknown) => {
const res = await fetch(fallbackUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ctx),
});
try { return await res.json(); }
catch { return { success: false, error: `${componentId} fallback 失敗` }; }
};
}
// 4. API recipe 零件
const recipe = API_RECIPES[componentId];
if (recipe) {
return async (ctx: unknown) => recipe(ctx as Record<string, unknown>);
}
// 5. 找不到
throw new Error(
`找不到零件 "${componentId}"。\n` +
`可用邏輯零件:${Object.keys(LOGIC_COMPONENT_URLS).join(', ')}\n` +
`可用 API 零件:${Object.keys(API_RECIPES).join(', ')}\n` +
`也可傳入外部 URLhttps://...)作為零件。`
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 }),
};
+34
View File
@@ -0,0 +1,34 @@
/**
* 穩定 ID 衍生工具
*
* 邏輯零件: cmp_<sha256(canonical_id)[:8]>
* API reciperec_<sha256(canonical_id)[:8]>
*
* 同一個 canonical_id 永遠得到同一個 hash
* 讓 workflow 可以用 hash 引用零件,不受改名影響。
*/
export async function deriveComponentHash(canonicalId: string): Promise<string> {
return 'cmp_' + await sha256Prefix(canonicalId);
}
export async function deriveRecipeHash(canonicalId: string): Promise<string> {
return 'rec_' + await sha256Prefix(canonicalId);
}
export function isComponentHash(id: string): boolean {
return /^cmp_[0-9a-f]{8}$/.test(id);
}
export function isRecipeHash(id: string): boolean {
return /^rec_[0-9a-f]{8}$/.test(id);
}
async function sha256Prefix(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const buf = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buf))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 8);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* /recipes — API recipe CRUD
*
* recipe 是「http_request + 參數模板」的具名封裝。
* 不需要 deploy Worker,執行時由 cypher-executor 直接 fetch。
*
* KV 結構:
* recipe:{canonical_id} → RecipeDefinition JSON
* idx:{rec_hash} → canonical_id (反查索引)
*
* 引用方式(workflow config):
* component: "rec_f7e2a1b3" → 永久穩定,不受改名影響
* component: "slack" → 向前兼容,直接用 canonical_id 查
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash';
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
export interface RecipeDefinition {
canonical_id: string;
hash_id: string; // rec_xxxxxxxx
display_name?: string;
description?: string;
endpoint: string;
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
headers?: Record<string, string>;
body?: Record<string, unknown>;
credentials_required?: Array<{
key: string;
inject_as: string;
}>;
created_at: number;
updated_at: number;
}
// POST /recipes — 新增或更新 recipe
recipesRouter.post('/recipes', async (c) => {
let body: Partial<RecipeDefinition>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
const canonicalId = (body.canonical_id ?? '').trim().toLowerCase();
if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400);
if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400);
const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now();
// 讀取現有版本(保留 created_at
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
const recipe: RecipeDefinition = {
canonical_id: canonicalId,
hash_id: hashId,
display_name: body.display_name,
description: body.description,
endpoint: body.endpoint,
method: (body.method ?? 'POST').toUpperCase(),
headers: body.headers,
body: body.body,
credentials_required: body.credentials_required,
created_at: existing?.created_at ?? now,
updated_at: now,
};
// 寫入兩個 KV key
await Promise.all([
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
return c.json({ success: true, recipe });
});
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash
recipesRouter.get('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
return c.json({ success: true, recipe });
});
// GET /recipes — 列出所有 recipe
recipesRouter.get('/recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
const recipes = await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
);
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
});
// DELETE /recipes/:id — 刪除 recipe
recipesRouter.delete('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
await Promise.all([
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`),
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
]);
return c.json({ success: true, deleted: recipe.canonical_id });
});
/** 用 canonical_id 或 rec_hash 查 recipe */
export async function resolveRecipe(
id: string,
kv: KVNamespace,
): Promise<RecipeDefinition | null> {
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
if (id.startsWith('rec_')) {
const canonicalId = await kv.get(`idx:${id}`);
if (!canonicalId) return null;
return kv.get(`recipe:${canonicalId}`, 'json');
}
// 直接用 canonical_id
return kv.get(`recipe:${id}`, 'json');
}
+2
View File
@@ -24,6 +24,8 @@ export type Bindings = {
SVC_AI_TRANSFORM_RUN: ServiceBinding;
// KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
EXEC_CONTEXT: KVNamespace;
// Recipe StoreAPI recipe 定義(key: recipe:{canonical_id} 或 idx:{hash_id}
RECIPES: KVNamespace;
// Webhook Storekey = workflow namevalue = Workflow JSON
WEBHOOKS: KVNamespace;
// Credential StoreAES-GCM 加密存放用戶 API token
+4
View File
@@ -19,6 +19,10 @@ id = "e7f4320f88d343f187e35e3543dd74c9"
binding = "ANALYTICS_KV"
id = "a43b7997c8e54a34886c2995a853c720"
[[kv_namespaces]]
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
[[r2_buckets]]
binding = "WASM_BUCKET"
bucket_name = "arcrun-wasm"