diff --git a/component-worker-template/package.json b/component-worker-template/package.json new file mode 100644 index 0000000..7a4f65c --- /dev/null +++ b/component-worker-template/package.json @@ -0,0 +1,14 @@ +{ + "name": "arcrun-component-worker-template", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250408.0", + "typescript": "^5.4.0", + "wrangler": "^4.0.0" + } +} diff --git a/component-worker-template/src/index.ts b/component-worker-template/src/index.ts new file mode 100644 index 0000000..e77ce19 --- /dev/null +++ b/component-worker-template/src/index.ts @@ -0,0 +1,142 @@ +/** + * arcrun logic component Worker + * + * POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output + * + * WASM is statically bundled at build time via wrangler.toml [[wasm_modules]]. + * Each logic component gets its own Worker at {name}.arcrun.dev. + */ + +import componentWasm from '../component.wasm' assert { type: 'webassembly' }; + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; + +const app = new Hono(); + +app.use('*', cors()); + +app.get('/', (c) => c.json({ ok: true, component: COMPONENT_ID })); + +app.post('/', async (c) => { + let input: unknown; + try { + input = await c.req.json(); + } catch { + return c.json({ success: false, error: 'request body must be JSON' }, 400); + } + + try { + const result = await runWasm(componentWasm, input); + return c.json(result); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 500); + } +}); + +export default app; + +// ── WASM runner (WASI preview1 stdin/stdout) ───────────────────────────────── + +declare const COMPONENT_ID: string; // injected via [vars] in wrangler.toml + +async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise { + const stdinBytes = new TextEncoder().encode(JSON.stringify(input)); + let stdinOffset = 0; + + const stdoutChunks: Uint8Array[] = []; + let memory: WebAssembly.Memory | null = null; + + const getView = () => new DataView(memory!.buffer); + + const wasi: WebAssembly.Imports = { + wasi_snapshot_preview1: { + fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number { + if (fd !== 1 && fd !== 2) return 76; // ENOSYS + const view = getView(); + let total = 0; + for (let i = 0; i < iovs_len; i++) { + const base = view.getUint32(iovs + i * 8, true); + const len = view.getUint32(iovs + i * 8 + 4, true); + if (len === 0) continue; + const chunk = new Uint8Array(memory!.buffer, base, len); + const copy = new Uint8Array(len); + copy.set(chunk); + if (fd === 1) stdoutChunks.push(copy); + total += len; + } + view.setUint32(nwritten_ptr, total, true); + return 0; + }, + fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number { + if (fd !== 0) return 76; + const view = getView(); + let total = 0; + for (let i = 0; i < iovs_len; i++) { + const base = view.getUint32(iovs + i * 8, true); + const len = view.getUint32(iovs + i * 8 + 4, true); + const remaining = stdinBytes.length - stdinOffset; + if (remaining <= 0) break; + const toCopy = Math.min(len, remaining); + new Uint8Array(memory!.buffer, base, toCopy).set( + stdinBytes.subarray(stdinOffset, stdinOffset + toCopy) + ); + stdinOffset += toCopy; + total += toCopy; + } + view.setUint32(nread_ptr, total, true); + return 0; + }, + proc_exit(code: number): never { throw new Error(`wasm exit: ${code}`); }, + random_get(ptr: number, len: number): number { + crypto.getRandomValues(new Uint8Array(memory!.buffer, ptr, len)); + return 0; + }, + fd_seek: () => 76, fd_close: () => 0, + fd_fdstat_get: () => 76, fd_prestat_get: () => 76, + fd_prestat_dir_name: () => 76, environ_get: () => 0, + environ_sizes_get: (cp: number, sp: number) => { + if (memory) { const v = getView(); v.setUint32(cp,0,true); v.setUint32(sp,0,true); } + return 0; + }, + args_get: () => 0, + args_sizes_get: (ap: number, bp: number) => { + if (memory) { const v = getView(); v.setUint32(ap,0,true); v.setUint32(bp,0,true); } + return 0; + }, + clock_time_get: (_id: number, _prec: bigint, tp: number) => { + if (memory) getView().setBigUint64(tp, BigInt(Date.now()) * 1_000_000n, true); + return 0; + }, + clock_res_get: () => 76, poll_oneoff: () => 76, sched_yield: () => 0, + proc_raise: () => 76, sock_accept: () => 76, sock_recv: () => 76, + sock_send: () => 76, sock_shutdown: () => 76, + path_open: () => 76, path_create_directory: () => 76, + path_remove_directory: () => 76, path_rename: () => 76, + path_unlink_file: () => 76, path_filestat_get: () => 76, + path_readlink: () => 76, path_symlink: () => 76, path_link: () => 76, + }, + // u6u host functions (no-op for pure logic components) + u6u: { http_request: () => 1 }, + }; + + const instance = await WebAssembly.instantiate(wasmModule, wasi); + memory = instance.exports.memory as WebAssembly.Memory; + + const start = (instance.exports._start ?? instance.exports.main) as () => void; + if (typeof start !== 'function') throw new Error('WASM missing _start or main export'); + + try { start(); } catch (e) { + if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; + } + + const decoder = new TextDecoder(); + const total = stdoutChunks.reduce((n, c) => n + c.length, 0); + const merged = new Uint8Array(total); + let off = 0; + for (const chunk of stdoutChunks) { merged.set(chunk, off); off += chunk.length; } + const stdout = decoder.decode(merged).trim(); + + if (!stdout) throw new Error('WASM component produced no output'); + return JSON.parse(stdout); +} diff --git a/component-worker-template/tsconfig.json b/component-worker-template/tsconfig.json new file mode 100644 index 0000000..b65fda7 --- /dev/null +++ b/component-worker-template/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true + } +} diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index 5725161..371490d 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -1,75 +1,193 @@ +/** + * 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 函數,不需要網路呼叫 + */ + import { BUILTIN_COMPONENTS } from './constants'; -import { createWasiShim } from './wasi-shim'; import type { Bindings, ComponentRunner } from '../types'; -// Worker 記憶體快取:componentId → WebAssembly.Module(跨請求共享,避免重複編譯) -const moduleCache = new Map(); +/** 邏輯零件 canonical_id → Worker URL */ +const LOGIC_COMPONENT_URLS: Record = { + if_control: 'https://if-control.arcrun.dev', + switch: 'https://switch.arcrun.dev', + foreach_control: 'https://foreach-control.arcrun.dev', + filter: 'https://filter.arcrun.dev', + merge: 'https://merge.arcrun.dev', + try_catch: 'https://try-catch.arcrun.dev', + wait: 'https://wait.arcrun.dev', + set: 'https://set.arcrun.dev', + array_ops: 'https://array-ops.arcrun.dev', + string_ops: 'https://string-ops.arcrun.dev', + number_ops: 'https://number-ops.arcrun.dev', + date_ops: 'https://date-ops.arcrun.dev', + validate_json: 'https://validate-json.arcrun.dev', + ai_transform_compile:'https://ai-transform-compile.arcrun.dev', + ai_transform_run: 'https://ai-transform-run.arcrun.dev', +}; + +/** API 零件 canonical_id → recipe(endpoint + 組裝邏輯)*/ +const API_RECIPES: Record) => Promise> = { + http_request: async (ctx) => { + const url = ctx.url as string; + const method = (ctx.method as string ?? 'GET').toUpperCase(); + const headers = (ctx.headers as Record) ?? {}; + const body = ctx.body !== undefined ? JSON.stringify(ctx.body) : undefined; + if (!url) return { success: false, error: 'url 必填' }; + 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 { to, subject, body, access_token } = ctx as Record; + 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 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' }, + body: JSON.stringify({ raw: encoded }), + }); + const data = await res.json(); + return { success: res.ok, data }; + }, + + telegram: async (ctx) => { + const { bot_token, chat_id, text } = ctx as Record; + if (!bot_token) return { success: false, error: 'bot_token 必填(由 credentials 注入)' }; + const res = await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id, text }), + }); + const data = await res.json(); + return { success: res.ok, data }; + }, + + line_notify: async (ctx) => { + const { token, message } = ctx as Record; + if (!token) return { success: false, error: 'token 必填(由 credentials 注入)' }; + const form = new URLSearchParams({ message }); + 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(), + }); + const data = await res.json(); + return { success: res.ok, data }; + }, + + google_sheets: async (ctx) => { + const { access_token, spreadsheet_id, range, values, operation } = ctx as Record; + 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 res = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheet_id}/values/${range}`, + { headers } + ); + 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 }; + } + }, + + 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 }; + }, +}; -/** - * 建立零件載入器 - * - * 三層優先序: - * 1. 內建零件(BUILTIN_COMPONENTS,純本地轉換,不需 R2) - * 2. WASM_BUCKET R2 直讀 → {componentId}/{componentId}.wasm - * 3. 找不到 → 結構化錯誤(含 R2 key 與修復說明) - */ export function createComponentLoader(env: Bindings) { return async (componentId: string): Promise => { - // 層 1:內建零件(無需 R2) + + // 1. 內建零件(純 JS,最優先) const builtin = BUILTIN_COMPONENTS.get(componentId); if (builtin) return builtin; - // 層 2:從 WASM_BUCKET R2 讀取(快取 Module 避免重複編譯) - const wasmKey = `${componentId}/${componentId}.wasm`; - - let wasmModule = moduleCache.get(componentId); - if (!wasmModule) { - const wasmObj = await env.WASM_BUCKET.get(wasmKey); - if (!wasmObj) { - throw new Error( - `零件 ${componentId} 不存在。\n` + - `請確認 ${wasmKey} 已上傳至 WASM_BUCKET。\n` + - `修復:執行 acr parts 查看可用零件清單。` - ); - } - const buffer = await wasmObj.arrayBuffer(); - wasmModule = await WebAssembly.compile(buffer); - moduleCache.set(componentId, wasmModule); + // 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() }; } + }; } - const compiledModule = wasmModule; - return async (ctx: unknown): Promise => { - const stdinJson = JSON.stringify(ctx); - const shim = createWasiShim(stdinJson); - - const instance = await WebAssembly.instantiate(compiledModule, shim.imports); - - const memory = instance.exports.memory as WebAssembly.Memory | undefined; - if (memory) shim.setMemory(memory); - - const exports = instance.exports as Record; - const entryFn = (exports._start ?? exports.main) as (() => void) | undefined; - if (typeof entryFn !== 'function') { - throw new Error(`WASM 零件缺少 _start 或 main export(${componentId})`); - } - - try { - entryFn(); - } catch (e) { - // proc_exit(0) 拋出 "wasm exit: 0",視為正常結束 - if (!(e instanceof Error && e.message === 'wasm exit: 0')) { - throw e; + // 3. 邏輯零件 Worker + const logicUrl = LOGIC_COMPONENT_URLS[componentId]; + if (logicUrl) { + return async (ctx: unknown) => { + const res = await fetch(logicUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ctx), + }); + if (!res.ok) { + const text = await res.text(); + return { success: false, error: `${componentId} Worker 回傳 ${res.status}: ${text.slice(0, 200)}` }; } - } + try { return await res.json(); } + catch { return { success: false, error: `${componentId} Worker 回傳非 JSON` }; } + }; + } - const stdout = shim.getStdout().trim(); - if (!stdout) throw new Error(`WASM 零件沒有輸出(stdout 為空):${componentId}`); + // 4. API recipe 零件 + const recipe = API_RECIPES[componentId]; + if (recipe) { + return async (ctx: unknown) => recipe(ctx as Record); + } - try { - return JSON.parse(stdout); - } catch { - throw new Error(`WASM 零件輸出不是合法 JSON:${stdout.slice(0, 200)}`); - } - }; + // 5. 找不到 + throw new Error( + `找不到零件 "${componentId}"。\n` + + `可用邏輯零件:${Object.keys(LOGIC_COMPONENT_URLS).join(', ')}\n` + + `可用 API 零件:${Object.keys(API_RECIPES).join(', ')}\n` + + `也可傳入外部 URL(https://...)作為零件。` + ); }; } diff --git a/scripts/deploy-logic-components.sh b/scripts/deploy-logic-components.sh new file mode 100755 index 0000000..77ba719 --- /dev/null +++ b/scripts/deploy-logic-components.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Deploy all logic components as individual CF Workers +# Each component gets: {name}.arcrun.dev +# +# Usage: bash scripts/deploy-logic-components.sh [component_name] +# (no arg = deploy all logic components) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +COMPONENTS_DIR="$REPO_ROOT/registry/components" +TEMPLATE_DIR="$REPO_ROOT/component-worker-template" +BUILD_DIR="$REPO_ROOT/.component-builds" + +# Logic components only (no_network_syscall: true) +LOGIC_COMPONENTS=( + if_control + switch + foreach_control + filter + merge + try_catch + wait + set + array_ops + string_ops + number_ops + date_ops + validate_json + ai_transform_compile + ai_transform_run +) + +# Filter to single component if arg provided +if [ -n "$1" ]; then + LOGIC_COMPONENTS=("$1") +fi + +mkdir -p "$BUILD_DIR" + +deploy_component() { + local name="$1" + local worker_name="arcrun-${name//_/-}" # e.g. string_ops → arcrun-string-ops + local route_name="${name//_/-}" # e.g. string_ops → string-ops + local wasm_file="$COMPONENTS_DIR/$name/${name}.wasm" + local build_target="$BUILD_DIR/$name" + + echo "" + echo "── $name ──────────────────────────────────" + + # 1. Compile WASM if not present + if [ ! -f "$wasm_file" ]; then + echo " Compiling WASM..." + (cd "$COMPONENTS_DIR/$name" && tinygo build -o "${name}.wasm" -target=wasi ./...) + fi + + # 2. Create per-component build dir + mkdir -p "$build_target/src" + + # 3. Copy template source + cp "$TEMPLATE_DIR/src/index.ts" "$build_target/src/index.ts" + cp "$TEMPLATE_DIR/package.json" "$build_target/package.json" + cp "$TEMPLATE_DIR/tsconfig.json" "$build_target/tsconfig.json" + + # 4. Copy WASM into build dir as component.wasm + cp "$wasm_file" "$build_target/component.wasm" + + # 5. Generate wrangler.toml + cat > "$build_target/wrangler.toml" << TOML +name = "$worker_name" +main = "src/index.ts" +compatibility_date = "2025-02-19" + +[vars] +COMPONENT_ID = "$name" + +[[routes]] +pattern = "${route_name}.arcrun.dev/*" +zone_name = "arcrun.dev" +TOML + + # 6. Install deps (reuse node_modules if already installed) + if [ ! -d "$build_target/node_modules" ]; then + echo " Installing deps..." + (cd "$build_target" && npm install --legacy-peer-deps --silent) + fi + + # 7. Deploy + echo " Deploying to $worker_name ($route_name.arcrun.dev)..." + (cd "$build_target" && npx wrangler deploy) + + echo " ✓ $name → https://${route_name}.arcrun.dev" +} + +echo "Deploying ${#LOGIC_COMPONENTS[@]} logic component(s)..." + +for name in "${LOGIC_COMPONENTS[@]}"; do + if [ ! -d "$COMPONENTS_DIR/$name" ]; then + echo " ✗ $name: directory not found, skipping" + continue + fi + deploy_component "$name" +done + +echo "" +echo "Done."