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:
@@ -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;
|
||||
|
||||
@@ -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 讀取 recipe,fetch 外部 API
|
||||
* 不需要獨立 Worker,整個執行在 cypher-executor 裡完成
|
||||
*
|
||||
* 3. 外部 URL 零件(componentId 以 http:// 或 https:// 開頭)
|
||||
* → 直接 fetch,可以是 n8n webhook、MCP endpoint 等任何 HTTP 服務
|
||||
*
|
||||
* 4. 內建零件(BUILTIN_COMPONENTS)
|
||||
* → 純 JS 函數,不需要網路呼叫
|
||||
* 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 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 → recipe(endpoint + 組裝邏輯)*/
|
||||
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` +
|
||||
`或傳入外部 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 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. 外部 URL(componentId 直接是 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 Binding(CF 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` +
|
||||
`也可傳入外部 URL(https://...)作為零件。`
|
||||
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 }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 穩定 ID 衍生工具
|
||||
*
|
||||
* 邏輯零件: cmp_<sha256(canonical_id)[:8]>
|
||||
* API recipe:rec_<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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export type Bindings = {
|
||||
SVC_AI_TRANSFORM_RUN: ServiceBinding;
|
||||
// KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
|
||||
EXEC_CONTEXT: KVNamespace;
|
||||
// Recipe Store:API recipe 定義(key: recipe:{canonical_id} 或 idx:{hash_id})
|
||||
RECIPES: KVNamespace;
|
||||
// Webhook Store:key = workflow name,value = Workflow JSON
|
||||
WEBHOOKS: KVNamespace;
|
||||
// Credential Store:AES-GCM 加密存放用戶 API token
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user