feat(arcrun): implement arcrun MVP — open-source AI workflow engine
Phase 1-5 complete per .agents/specs/u6u-core-mvp/: **Phase 1 — Cherry-pick & cleanup** - Create arcrun/ from cypher-executor, credentials, builtins, registry - Remove 9 InkStone Service Bindings (KBDB, REGISTRY, CLINIC_*, AICEO, MINI_ME) - Rewrite component-loader: 3-layer (builtin → WASM_BUCKET R2 → error) - Remove autoPublishMissing.ts, proxy.ts (AICEO), execution-logger.ts (KBDB) - Clean all KV namespace IDs and InkStone internal URLs from config files **Phase 2 — contract.yaml completeness** - Add credentials_required to gmail, google_sheets, telegram, line_notify - Add config_example to all 21 components with annotated field descriptions **Phase 3 — Credential injection** - Add credential-injector.ts: AES-GCM decrypt from CREDENTIALS_KV - Integrate into GraphExecutor before WASM execution - Structured errors with repair instructions when credential missing **Phase 4 — CLI (acr)** - cli/package.json: arcrun package, bin: acr, deps: commander/js-yaml/chalk/ora - 8 commands: init, creds push, push, run, validate, parts, list, logs - Standard mode: writes directly to user's CF KV via CF REST API - acr init: interactive setup with arcrun.dev API Key registration **Phase 5 — Open source release prep** - README.md: 5-minute quickstart, component table, workflow YAML syntax - CONTRIBUTING.md: TinyGo dev env, component scaffolding, submission flow - Security audit: no InkStone internal URLs/IDs in committed files - .gitignore: exclude credentials.yaml, .wrangler, *.wasm https://claude.ai/code/session_01BnCdSLVH8tUed9VrrPavgT
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { BUILTIN_COMPONENTS } from './constants';
|
||||
import type { Bindings, ComponentRunner } from '../types';
|
||||
|
||||
/**
|
||||
* 建立零件載入器
|
||||
*
|
||||
* 三層優先序:
|
||||
* 1. 內建零件(BUILTIN_COMPONENTS,純本地轉換,不需 R2)
|
||||
* 2. WASM_BUCKET R2 直讀 → {componentId}/{componentId}.wasm
|
||||
* 3. 找不到 → 結構化錯誤(含 R2 key 與修復說明)
|
||||
*/
|
||||
export function createComponentLoader(env: Bindings) {
|
||||
return async (componentId: string): Promise<ComponentRunner> => {
|
||||
// 層 1:內建零件(無需 R2)
|
||||
const builtin = BUILTIN_COMPONENTS.get(componentId);
|
||||
if (builtin) return builtin;
|
||||
|
||||
// 層 2:從 WASM_BUCKET R2 讀取
|
||||
const wasmKey = `${componentId}/${componentId}.wasm`;
|
||||
const wasmObj = await env.WASM_BUCKET.get(wasmKey);
|
||||
if (wasmObj) {
|
||||
const wasmBuffer = await wasmObj.arrayBuffer();
|
||||
return createWasmRunner(componentId, wasmBuffer, env);
|
||||
}
|
||||
|
||||
// 層 3:找不到
|
||||
throw new Error(
|
||||
`零件 ${componentId} 不存在。\n` +
|
||||
`請確認 ${wasmKey} 已上傳至 WASM_BUCKET。\n` +
|
||||
`修復:執行 acr parts 查看可用零件清單。`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WASM 零件執行器
|
||||
* 使用 WASI preview1 stdin/stdout JSON I/O 模型
|
||||
*/
|
||||
function createWasmRunner(
|
||||
componentId: string,
|
||||
wasmBuffer: ArrayBuffer,
|
||||
_env: Bindings,
|
||||
): ComponentRunner {
|
||||
return async (ctx: unknown): Promise<unknown> => {
|
||||
// 動態 import wasm-executor(避免頂層 import 造成 Worker 啟動問題)
|
||||
const { executeWasm } = await import('./wasm-executor');
|
||||
return executeWasm(componentId, wasmBuffer, ctx);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ComponentRunner, EdgeType } from '../types';
|
||||
|
||||
export const VALID_EDGE_TYPES = new Set([
|
||||
// 現有
|
||||
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
||||
// 新增:執行語意
|
||||
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
||||
// 新增:觸發語意
|
||||
'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
// 新增:結構語意(記錄圖結構,不執行)
|
||||
'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR',
|
||||
]);
|
||||
|
||||
/** 內建零件 ID 集合(不需要查 WASM_BUCKET,Worker 記憶體中已有實作)*/
|
||||
export const BUILTIN_IDS = new Set([
|
||||
'webhook', 'comp_passthrough', 'comp_uppercase', 'comp_counter',
|
||||
]);
|
||||
|
||||
/** 語意邊 → EdgeType 映射(ADR-057 u6u L1:支援中文語意關係詞)
|
||||
* 完成後 → PIPE(成功後觸發下一個)
|
||||
* 失敗時 → CONTINUE(失敗後繼續)
|
||||
* 對每個 → FOREACH(迭代執行)
|
||||
* 條件滿足時 → IF(條件分支)
|
||||
*/
|
||||
export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
// 中文語意詞
|
||||
'完成後': 'PIPE',
|
||||
'失敗時': 'ON_FAIL',
|
||||
'對每個': 'FOREACH',
|
||||
'條件滿足時': 'IF',
|
||||
// 英文別名
|
||||
'SUCCESS': 'ON_SUCCESS',
|
||||
'FAIL': 'ON_FAIL',
|
||||
'CLICK': 'ON_CLICK',
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數,不需要 R2)
|
||||
* WASM 零件從 WASM_BUCKET R2 直接讀取
|
||||
*/
|
||||
export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
['comp_passthrough', (ctx) => ctx],
|
||||
['comp_uppercase', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, text: String(c.text || '').toUpperCase() };
|
||||
}],
|
||||
['comp_counter', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
@@ -0,0 +1,306 @@
|
||||
export const OPENAPI_SPEC = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'arcrun cypher-executor API',
|
||||
description: 'AI Workflow Execution Engine — 透過三元組 Triplet 或圖 Graph 定義工作流,系統執行並回傳結果',
|
||||
version: '1.0.0',
|
||||
contact: {
|
||||
name: 'arcrun',
|
||||
url: 'https://github.com/arcrun/arcrun',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{ url: 'https://cypher.arcrun.dev', description: 'arcrun.dev Hosted' },
|
||||
{ url: 'http://localhost:8787', description: 'Local Development' },
|
||||
],
|
||||
paths: {
|
||||
'/': {
|
||||
get: {
|
||||
summary: 'Health Check',
|
||||
tags: ['Health'],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Service is running',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/search': {
|
||||
post: {
|
||||
summary: '搜尋工作流需要的零件',
|
||||
tags: ['Cypher'],
|
||||
description: '用三元組描述工作流,系統解析並從 Registry 查詢對應零件',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['start >> 完成後 >> get-data', 'get-data >> 完成後 >> done'],
|
||||
description: '三元組陣列,格式:\"FROM >> ACTION >> TO\"',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生發佈',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '零件搜尋成功(含版本號和時戳,適合 Markdown 文檔追蹤)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'search-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
triplets: { type: 'array', items: { type: 'string' }, description: '回送的三元組列表' },
|
||||
nodes: { type: 'object', description: '搜尋到的零件及其狀態' },
|
||||
cypher: { type: 'object', description: '工作流圖(null 若有缺失零件)' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '缺失零件列表' },
|
||||
auto_published: { type: 'object', description: '自動發佈的零件(若 auto_publish=true)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': { description: '無法解析三元組' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/execute': {
|
||||
post: {
|
||||
summary: '執行工作流',
|
||||
tags: ['Cypher'],
|
||||
description: '直接執行 triplets,回傳完整執行結果。支援自動發佈缺失零件。',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '三元組陣列,格式:"FROM >> ACTION >> TO"',
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: '執行上下文,傳入各節點作為初始參數',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生臨時實作',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '執行成功(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
data: { type: 'object', description: '執行結果' },
|
||||
trace: { type: 'array', description: '執行跟蹤' },
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
description: '執行失敗或部份零件缺失(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
error: { type: 'string' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '無法自動發佈的缺失零件' },
|
||||
auto_published: {
|
||||
type: 'object',
|
||||
description: '自動發佈的零件資訊',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
componentId: { type: 'string' },
|
||||
temporary_endpoint: { type: 'string', format: 'uri', description: '臨時實作的 URL' },
|
||||
implement_by: { type: 'string', format: 'date-time', description: '實作截止時間' },
|
||||
},
|
||||
},
|
||||
},
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks': {
|
||||
post: {
|
||||
summary: '建立 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
description: '將工作流註冊成 Webhook,得到公開 URL',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
description: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': {
|
||||
description: 'Webhook 建立成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
webhook_url: { type: 'string', format: 'uri' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
get: {
|
||||
summary: '列出所有 Webhooks',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'Authorization',
|
||||
in: 'header',
|
||||
required: true,
|
||||
schema: { type: 'string', example: 'Bearer u6u_xxxxx' },
|
||||
description: 'API Key 認證',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhooks 列表',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhooks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
total: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': { description: '未授權' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks/{token}': {
|
||||
get: {
|
||||
summary: '查詢單個 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhook 資訊',
|
||||
},
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: '刪除 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': { description: 'Webhook 已刪除' },
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// 圖定義的 Zod Schema
|
||||
export const graphSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
nodes: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['Input', 'Component', 'Output']),
|
||||
componentId: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
})),
|
||||
edges: z.array(z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE']),
|
||||
condition: z.string().optional(),
|
||||
iterator: z.string().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const executeSchema = z.object({
|
||||
graph: graphSchema,
|
||||
context: z.record(z.unknown()).default({}),
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* WASI preview1 輕量 shim
|
||||
* 只實作 stdin/stdout/stderr 所需的最小 syscall 集合。
|
||||
* 其餘 syscall 一律回傳 ENOSYS(76),確保零件無法呼叫網路或檔案系統。
|
||||
*
|
||||
* 不依賴任何外部套件(不使用 @cloudflare/workers-wasi)。
|
||||
* Requirements: 3.1, 3.3
|
||||
*/
|
||||
|
||||
const WASI_ESUCCESS = 0;
|
||||
const WASI_ENOSYS = 76;
|
||||
|
||||
// fd 常數
|
||||
const FD_STDIN = 0;
|
||||
const FD_STDOUT = 1;
|
||||
const FD_STDERR = 2;
|
||||
|
||||
export interface WasiShim {
|
||||
/** WebAssembly.Imports 物件,傳入 WebAssembly.instantiate */
|
||||
imports: WebAssembly.Imports;
|
||||
/** 取得 stdout 的完整輸出(合併所有 chunks) */
|
||||
getStdout(): string;
|
||||
/** 取得 stderr 的完整輸出 */
|
||||
getStderr(): string;
|
||||
/** 注入 WebAssembly.Memory(instantiate 後呼叫) */
|
||||
setMemory(memory: WebAssembly.Memory): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host function 注入介面
|
||||
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
|
||||
*/
|
||||
export interface WasiHostFunctions {
|
||||
/** HTTP 請求 host function:.wasm 呼叫此函數發出 HTTP 請求 */
|
||||
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WASI shim 實例
|
||||
* @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input))
|
||||
* @param hostFunctions - 可選的 host function 注入(讓 .wasm 呼叫外部服務)
|
||||
*/
|
||||
export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFunctions): WasiShim {
|
||||
const stdinBytes = new TextEncoder().encode(stdinData);
|
||||
let stdinOffset = 0;
|
||||
|
||||
const stdoutChunks: Uint8Array[] = [];
|
||||
const stderrChunks: Uint8Array[] = [];
|
||||
|
||||
let memory: WebAssembly.Memory | null = null;
|
||||
|
||||
function getMemoryView(): DataView {
|
||||
if (!memory) throw new Error('WASI memory not set — call setMemory() after instantiate');
|
||||
return new DataView(memory.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* fd_write: 將 iovec 陣列的資料寫入 fd(stdout=1 或 stderr=2)
|
||||
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 bytes,little-endian)
|
||||
*/
|
||||
function fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
|
||||
if (fd !== FD_STDOUT && fd !== FD_STDERR) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalWritten = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
const chunk = new Uint8Array(buf, iov_base, iov_len);
|
||||
const copy = new Uint8Array(iov_len);
|
||||
copy.set(chunk);
|
||||
if (fd === FD_STDOUT) stdoutChunks.push(copy);
|
||||
else stderrChunks.push(copy);
|
||||
totalWritten += iov_len;
|
||||
}
|
||||
|
||||
view.setUint32(nwritten_ptr, totalWritten, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* fd_read: 從 stdin 讀取資料到 iovec 陣列
|
||||
*/
|
||||
function fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
|
||||
if (fd !== FD_STDIN) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalRead = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
|
||||
const remaining = stdinBytes.length - stdinOffset;
|
||||
if (remaining <= 0) break;
|
||||
|
||||
const toCopy = Math.min(iov_len, remaining);
|
||||
const dest = new Uint8Array(buf, iov_base, toCopy);
|
||||
dest.set(stdinBytes.subarray(stdinOffset, stdinOffset + toCopy));
|
||||
stdinOffset += toCopy;
|
||||
totalRead += toCopy;
|
||||
}
|
||||
|
||||
view.setUint32(nread_ptr, totalRead, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* proc_exit: 零件呼叫 exit(),拋出 Error 中止執行
|
||||
*/
|
||||
function proc_exit(code: number): never {
|
||||
throw new Error(`wasm exit: ${code}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* random_get: 填充隨機 bytes(使用 Web Crypto API)
|
||||
*/
|
||||
function random_get(buf_ptr: number, buf_len: number): number {
|
||||
const view = new Uint8Array(memory!.buffer, buf_ptr, buf_len);
|
||||
crypto.getRandomValues(view);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
const shim: WasiShim = {
|
||||
imports: {
|
||||
wasi_snapshot_preview1: { fd_write,
|
||||
fd_read,
|
||||
proc_exit,
|
||||
random_get,
|
||||
// 其餘 syscall 回傳 ENOSYS(不允許網路/檔案系統操作)
|
||||
fd_seek: () => WASI_ENOSYS,
|
||||
fd_close: () => WASI_ESUCCESS,
|
||||
fd_fdstat_get: () => WASI_ENOSYS,
|
||||
fd_prestat_get: () => WASI_ENOSYS,
|
||||
fd_prestat_dir_name: () => WASI_ENOSYS,
|
||||
environ_get: () => WASI_ESUCCESS,
|
||||
environ_sizes_get: (count_ptr: number, size_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
view.setUint32(count_ptr, 0, true);
|
||||
view.setUint32(size_ptr, 0, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
args_get: () => WASI_ESUCCESS,
|
||||
args_sizes_get: (argc_ptr: number, argv_buf_size_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
view.setUint32(argc_ptr, 0, true);
|
||||
view.setUint32(argv_buf_size_ptr, 0, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
clock_time_get: (id: number, precision: bigint, time_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
const now = BigInt(Date.now()) * 1_000_000n;
|
||||
view.setBigUint64(time_ptr, now, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
clock_res_get: () => WASI_ENOSYS,
|
||||
poll_oneoff: () => WASI_ENOSYS,
|
||||
sched_yield: () => WASI_ESUCCESS,
|
||||
proc_raise: () => WASI_ENOSYS,
|
||||
sock_accept: () => WASI_ENOSYS,
|
||||
sock_recv: () => WASI_ENOSYS,
|
||||
sock_send: () => WASI_ENOSYS,
|
||||
sock_shutdown: () => WASI_ENOSYS,
|
||||
path_open: () => WASI_ENOSYS,
|
||||
path_create_directory: () => WASI_ENOSYS,
|
||||
path_remove_directory: () => WASI_ENOSYS,
|
||||
path_rename: () => WASI_ENOSYS,
|
||||
path_unlink_file: () => WASI_ENOSYS,
|
||||
path_filestat_get: () => WASI_ENOSYS,
|
||||
path_readlink: () => WASI_ENOSYS,
|
||||
path_symlink: () => WASI_ENOSYS,
|
||||
path_link: () => WASI_ENOSYS,
|
||||
},
|
||||
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
||||
// .wasm 零件用 //go:wasmimport u6u http_request 宣告
|
||||
u6u: {
|
||||
http_request: hostFunctions?.http_request
|
||||
? async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
|
||||
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
|
||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
const buf = memory.buffer;
|
||||
const dec = new TextDecoder();
|
||||
const url = dec.decode(new Uint8Array(buf, urlPtr, urlLen));
|
||||
const method = dec.decode(new Uint8Array(buf, methodPtr, methodLen));
|
||||
const headers = dec.decode(new Uint8Array(buf, headersPtr, headersLen));
|
||||
const body = dec.decode(new Uint8Array(buf, bodyPtr, bodyLen));
|
||||
try {
|
||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||
const encoded = new TextEncoder().encode(result);
|
||||
// 寫入結果到 outPtr 指向的 buffer
|
||||
const view = new DataView(buf);
|
||||
new Uint8Array(buf, outPtr, encoded.length).set(encoded);
|
||||
view.setUint32(outLenPtr, encoded.length, true);
|
||||
return 0; // success
|
||||
} catch {
|
||||
return 1; // error
|
||||
}
|
||||
}
|
||||
: () => 1, // host function 未注入時回傳錯誤
|
||||
},
|
||||
},
|
||||
|
||||
setMemory(mem: WebAssembly.Memory) {
|
||||
memory = mem;
|
||||
},
|
||||
|
||||
getStdout(): string {
|
||||
if (stdoutChunks.length === 0) return '';
|
||||
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of stdoutChunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
},
|
||||
|
||||
getStderr(): string {
|
||||
if (stderrChunks.length === 0) return '';
|
||||
const total = stderrChunks.reduce((n, c) => n + c.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of stderrChunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
},
|
||||
};
|
||||
|
||||
return shim;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Tier 1 WASM 執行器
|
||||
* 從 R2 載入 .wasm,透過 WASI preview1 shim 執行,stdin/stdout JSON I/O。
|
||||
*
|
||||
* 快取策略:WebAssembly.Module 快取於 Worker 記憶體(跨請求共享),
|
||||
* 避免重複編譯。每次執行只重新 instantiate。
|
||||
*
|
||||
* Requirements: 3.1, 3.3, 6.6
|
||||
*/
|
||||
|
||||
import { createWasiShim, type WasiHostFunctions } from './wasi-shim';
|
||||
|
||||
// Worker 記憶體快取:r2Key → WebAssembly.Module
|
||||
const moduleCache = new Map<string, WebAssembly.Module>();
|
||||
|
||||
export interface WasmExecutorOptions {
|
||||
/** R2 Bucket binding */
|
||||
bucket: R2Bucket;
|
||||
/** R2 物件鍵(例:components/validate_json/v1.wasm) */
|
||||
r2Key: string;
|
||||
/** 逾時上限(ms),對應 contract.constraints.max_cold_start_ms */
|
||||
timeoutMs?: number;
|
||||
/** 可選的 host function 注入(讓 .wasm 呼叫外部服務) */
|
||||
hostFunctions?: WasiHostFunctions;
|
||||
}
|
||||
|
||||
export interface WasmExecuteResult {
|
||||
output: unknown;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 WASM 零件
|
||||
* @param input - 傳入零件的 JSON 物件(寫入 stdin)
|
||||
* @param options - 執行選項
|
||||
*/
|
||||
export async function executeWasm(
|
||||
input: unknown,
|
||||
options: WasmExecutorOptions,
|
||||
): Promise<WasmExecuteResult> {
|
||||
const { bucket, r2Key, timeoutMs = 50, hostFunctions } = options;
|
||||
|
||||
// ...(其餘不變)
|
||||
const start = Date.now();
|
||||
|
||||
// 1. 取得或編譯 WebAssembly.Module(快取)
|
||||
let wasmModule = moduleCache.get(r2Key);
|
||||
if (!wasmModule) {
|
||||
const obj = await bucket.get(r2Key);
|
||||
if (!obj) throw new Error(`WASM 零件不存在於 R2:${r2Key}`);
|
||||
const arrayBuffer = await obj.arrayBuffer();
|
||||
wasmModule = await WebAssembly.compile(arrayBuffer);
|
||||
moduleCache.set(r2Key, wasmModule);
|
||||
}
|
||||
|
||||
// 2. 建立 WASI shim,注入 stdin 與可選的 host functions
|
||||
const stdinJson = JSON.stringify(input);
|
||||
const shim = createWasiShim(stdinJson, hostFunctions);
|
||||
|
||||
// 3. instantiate(每次執行都重新 instantiate,shim 狀態是獨立的)
|
||||
const instance = await WebAssembly.instantiate(wasmModule, shim.imports);
|
||||
|
||||
// 4. 注入 memory(WASI fd_read/fd_write 需要存取 memory)
|
||||
const memory = instance.exports.memory as WebAssembly.Memory | undefined;
|
||||
if (memory) shim.setMemory(memory);
|
||||
|
||||
// 5. 執行(帶逾時)
|
||||
const exports = instance.exports as Record<string, unknown>;
|
||||
const entryFn = (exports._start ?? exports.main) as (() => void) | undefined;
|
||||
if (typeof entryFn !== 'function') {
|
||||
throw new Error(`WASM 零件缺少 _start 或 main export(r2Key: ${r2Key})`);
|
||||
}
|
||||
|
||||
const runWithTimeout = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`WASM 執行逾時(>${timeoutMs}ms):${r2Key}`));
|
||||
}, timeoutMs);
|
||||
try {
|
||||
entryFn();
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
// proc_exit(0) 拋出 "wasm exit: 0",視為正常結束
|
||||
if (e instanceof Error && e.message === 'wasm exit: 0') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await runWithTimeout;
|
||||
|
||||
// 6. 讀取 stdout,JSON.parse
|
||||
const stdout = shim.getStdout().trim();
|
||||
const stderr = shim.getStderr().trim();
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
if (!stdout) {
|
||||
throw new Error(`WASM 零件沒有輸出(stdout 為空):${r2Key}`);
|
||||
}
|
||||
|
||||
let output: unknown;
|
||||
try {
|
||||
output = JSON.parse(stdout);
|
||||
} catch {
|
||||
throw new Error(`WASM 零件輸出不是合法 JSON:${stdout.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return { output, stdout, stderr, duration_ms };
|
||||
}
|
||||
|
||||
/** 清除 Module 快取(測試用) */
|
||||
export function clearModuleCache(): void {
|
||||
moduleCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user