feat: component execution via Worker fetch + API recipes
- Logic components (15): each deployed as Worker at {name}.arcrun.dev,
cypher-executor fetches them via HTTP POST
- API components (6): gmail, telegram, line_notify, google_sheets,
http_request, cron executed inline via fetch recipes in component-loader
- External URL support: any https:// componentId is fetched directly
(n8n webhooks, MCP endpoints, etc.)
- Add deploy-logic-components.sh script for building/deploying WASM Workers
- Add component-worker-template with inline WASI shim
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<unknown> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { BUILTIN_COMPONENTS } from './constants';
|
||||||
import { createWasiShim } from './wasi-shim';
|
|
||||||
import type { Bindings, ComponentRunner } from '../types';
|
import type { Bindings, ComponentRunner } from '../types';
|
||||||
|
|
||||||
// Worker 記憶體快取:componentId → WebAssembly.Module(跨請求共享,避免重複編譯)
|
/** 邏輯零件 canonical_id → Worker URL */
|
||||||
const moduleCache = new Map<string, WebAssembly.Module>();
|
const LOGIC_COMPONENT_URLS: Record<string, string> = {
|
||||||
|
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<string, (ctx: Record<string, unknown>) => Promise<unknown>> = {
|
||||||
|
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;
|
||||||
|
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<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 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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
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<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 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) {
|
export function createComponentLoader(env: Bindings) {
|
||||||
return async (componentId: string): Promise<ComponentRunner> => {
|
return async (componentId: string): Promise<ComponentRunner> => {
|
||||||
// 層 1:內建零件(無需 R2)
|
|
||||||
|
// 1. 內建零件(純 JS,最優先)
|
||||||
const builtin = BUILTIN_COMPONENTS.get(componentId);
|
const builtin = BUILTIN_COMPONENTS.get(componentId);
|
||||||
if (builtin) return builtin;
|
if (builtin) return builtin;
|
||||||
|
|
||||||
// 層 2:從 WASM_BUCKET R2 讀取(快取 Module 避免重複編譯)
|
// 2. 外部 URL(componentId 直接是 http/https URL)
|
||||||
const wasmKey = `${componentId}/${componentId}.wasm`;
|
if (componentId.startsWith('http://') || componentId.startsWith('https://')) {
|
||||||
|
return async (ctx: unknown) => {
|
||||||
let wasmModule = moduleCache.get(componentId);
|
const res = await fetch(componentId, {
|
||||||
if (!wasmModule) {
|
method: 'POST',
|
||||||
const wasmObj = await env.WASM_BUCKET.get(wasmKey);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (!wasmObj) {
|
body: JSON.stringify(ctx),
|
||||||
throw new Error(
|
});
|
||||||
`零件 ${componentId} 不存在。\n` +
|
if (!res.ok) {
|
||||||
`請確認 ${wasmKey} 已上傳至 WASM_BUCKET。\n` +
|
const text = await res.text();
|
||||||
`修復:執行 acr parts 查看可用零件清單。`
|
return { success: false, status: res.status, error: text };
|
||||||
);
|
}
|
||||||
}
|
try { return await res.json(); }
|
||||||
const buffer = await wasmObj.arrayBuffer();
|
catch { return { success: true, data: await res.text() }; }
|
||||||
wasmModule = await WebAssembly.compile(buffer);
|
};
|
||||||
moduleCache.set(componentId, wasmModule);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const compiledModule = wasmModule;
|
// 3. 邏輯零件 Worker
|
||||||
return async (ctx: unknown): Promise<unknown> => {
|
const logicUrl = LOGIC_COMPONENT_URLS[componentId];
|
||||||
const stdinJson = JSON.stringify(ctx);
|
if (logicUrl) {
|
||||||
const shim = createWasiShim(stdinJson);
|
return async (ctx: unknown) => {
|
||||||
|
const res = await fetch(logicUrl, {
|
||||||
const instance = await WebAssembly.instantiate(compiledModule, shim.imports);
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const memory = instance.exports.memory as WebAssembly.Memory | undefined;
|
body: JSON.stringify(ctx),
|
||||||
if (memory) shim.setMemory(memory);
|
});
|
||||||
|
if (!res.ok) {
|
||||||
const exports = instance.exports as Record<string, unknown>;
|
const text = await res.text();
|
||||||
const entryFn = (exports._start ?? exports.main) as (() => void) | undefined;
|
return { success: false, error: `${componentId} Worker 回傳 ${res.status}: ${text.slice(0, 200)}` };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
try { return await res.json(); }
|
||||||
|
catch { return { success: false, error: `${componentId} Worker 回傳非 JSON` }; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
// 4. API recipe 零件
|
||||||
if (!stdout) throw new Error(`WASM 零件沒有輸出(stdout 為空):${componentId}`);
|
const recipe = API_RECIPES[componentId];
|
||||||
|
if (recipe) {
|
||||||
|
return async (ctx: unknown) => recipe(ctx as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// 5. 找不到
|
||||||
return JSON.parse(stdout);
|
throw new Error(
|
||||||
} catch {
|
`找不到零件 "${componentId}"。\n` +
|
||||||
throw new Error(`WASM 零件輸出不是合法 JSON:${stdout.slice(0, 200)}`);
|
`可用邏輯零件:${Object.keys(LOGIC_COMPONENT_URLS).join(', ')}\n` +
|
||||||
}
|
`可用 API 零件:${Object.keys(API_RECIPES).join(', ')}\n` +
|
||||||
};
|
`也可傳入外部 URL(https://...)作為零件。`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+107
@@ -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."
|
||||||
Reference in New Issue
Block a user