feat: 15 logic component Workers + cypher-executor auth/credentials routing

Component Workers:
- Deploys if_control, switch, filter, merge, try_catch, wait, set,
  array_ops, string_ops, number_ops, date_ops, validate_json,
  ai_transform_compile, ai_transform_run, foreach_control as
  independent Workers, backing cypher-executor's SVC_* service
  bindings (fast internal RPC for logic components).

cypher-executor routing:
- New routes: /auth (recipe resolution), /credentials (CRUD),
  /webhooks/named (user-friendly alias for cmp_/rec_ hashes).
- auth-recipe-seeds.ts: 20 pre-built platform auth recipes
  (Google Sheets, Gmail, Telegram, etc.) seeded into RECIPES KV.
- graph-executor + cypher-handlers + search-nodes updated for
  the new resolution chain.
- scripts/seed-auth-recipes.ts: one-shot tool to push seeds to KV.
- wrangler.toml: 15 SVC_* bindings wired to the new logic Workers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:40:02 +08:00
parent 6a3219e51b
commit 500d796573
92 changed files with 27237 additions and 72 deletions
File diff suppressed because it is too large Load Diff
@@ -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
}
}
@@ -0,0 +1,10 @@
name = "arcrun-ai-transform-compile"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "ai_transform_compile"
[[routes]]
pattern = "ai-transform-compile.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
@@ -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
}
}
@@ -0,0 +1,10 @@
name = "arcrun-ai-transform-run"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "ai_transform_run"
[[routes]]
pattern = "ai-transform-run.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-array-ops"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "array_ops"
[[routes]]
pattern = "array-ops.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-date-ops"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "date_ops"
[[routes]]
pattern = "date-ops.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-filter"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "filter"
[[routes]]
pattern = "filter.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
@@ -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
}
}
@@ -0,0 +1,10 @@
name = "arcrun-foreach-control"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "foreach_control"
[[routes]]
pattern = "foreach-control.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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
}
}
@@ -0,0 +1,10 @@
name = "arcrun-if-control"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "if_control"
[[routes]]
pattern = "if-control.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-merge"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "merge"
[[routes]]
pattern = "merge.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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
}
}
@@ -0,0 +1,10 @@
name = "arcrun-number-ops"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "number_ops"
[[routes]]
pattern = "number-ops.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-set"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "set"
[[routes]]
pattern = "set.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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
}
}
@@ -0,0 +1,10 @@
name = "arcrun-string-ops"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "string_ops"
[[routes]]
pattern = "string-ops.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-switch"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "switch"
[[routes]]
pattern = "switch.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-try-catch"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "try_catch"
[[routes]]
pattern = "try-catch.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
@@ -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
}
}
@@ -0,0 +1,10 @@
name = "arcrun-validate-json"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "validate_json"
[[routes]]
pattern = "validate-json.arcrun.dev/*"
zone_name = "arcrun.dev"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+142
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+10
View File
@@ -0,0 +1,10 @@
name = "arcrun-wait"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "wait"
[[routes]]
pattern = "wait.arcrun.dev/*"
zone_name = "arcrun.dev"
@@ -0,0 +1,51 @@
/**
* seed-auth-recipes.ts
*
* 將 auth-recipe-seeds.ts 中定義的 20 個 auth recipe 上傳至 cypher.arcrun.dev。
*
* 執行:
* npx tsx scripts/seed-auth-recipes.ts
*
* 環境變數:
* ARCRUN_API_URL - 預設 https://cypher.arcrun.dev
*/
import { AUTH_RECIPE_SEEDS } from '../src/lib/auth-recipe-seeds.js';
const BASE_URL = process.env.ARCRUN_API_URL ?? 'https://cypher.arcrun.dev';
async function main() {
console.log(`\n Seeding ${AUTH_RECIPE_SEEDS.length} auth recipes → ${BASE_URL}\n`);
let ok = 0;
let fail = 0;
for (const recipe of AUTH_RECIPE_SEEDS) {
process.stdout.write(` ${recipe.service.padEnd(24)} `);
try {
const res = await fetch(`${BASE_URL}/auth-recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
if (res.ok) {
console.log(``);
ok++;
} else {
const err = await res.text().catch(() => '');
console.log(`✗ HTTP ${res.status}: ${err.slice(0, 100)}`);
fail++;
}
} catch (e) {
console.log(`${e instanceof Error ? e.message : String(e)}`);
fail++;
}
}
console.log(`\n 完成:${ok} 成功,${fail} 失敗\n`);
if (fail > 0) process.exit(1);
}
main();
+4 -14
View File
@@ -17,11 +17,7 @@ export async function handleCypherSearch(
throw new Error('無法解析任何節點'); throw new Error('無法解析任何節點');
} }
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET); const { nodeResults } = searchNodes(parsed);
if (missingNodes.length > 0) {
return { nodes: nodeResults, cypher: null, missing: missingNodes };
}
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result'); const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] }; return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
@@ -35,20 +31,14 @@ export async function handleCypherExecute(
config: Record<string, Record<string, unknown>> | undefined, config: Record<string, Record<string, unknown>> | undefined,
env: Bindings, env: Bindings,
waitUntil: (promise: Promise<void>) => void, waitUntil: (promise: Promise<void>) => void,
apiKey?: string,
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number; graph?: ExecutionGraph }> { ): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number; graph?: ExecutionGraph }> {
const parsed = parseTriplets(triplets as unknown[]); const parsed = parseTriplets(triplets as unknown[]);
if (!parsed) { if (!parsed) {
throw new Error('無法解析任何節點'); throw new Error('無法解析任何節點');
} }
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET); const { nodeResults } = searchNodes(parsed, config);
if (missingNodes.length > 0) {
throw new Error(
`以下零件不存在於 WASM_BUCKET${missingNodes.join(', ')}\n` +
`修復:執行 acr parts 查看可用零件清單,或執行 acr validate <workflow.yaml> 進行完整驗證。`
);
}
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config); const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
const parseResult = graphSchema.safeParse(graph); const parseResult = graphSchema.safeParse(graph);
@@ -57,7 +47,7 @@ export async function handleCypherExecute(
} }
const loader = createComponentLoader(env); const loader = createComponentLoader(env);
const executor = new GraphExecutor(loader, undefined, env); const executor = new GraphExecutor(loader, undefined, env, apiKey);
const start = Date.now(); const start = Date.now();
try { try {
+15 -36
View File
@@ -1,7 +1,5 @@
import { BUILTIN_IDS } from '../lib/constants';
import type { ParsedTriplets, NodeRole } from './triplet-parser'; import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole } from './triplet-parser'; import { resolveNodeRole } from './triplet-parser';
import type { Bindings } from '../types';
export type SearchResult = { export type SearchResult = {
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>; nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
@@ -9,53 +7,34 @@ export type SearchResult = {
}; };
/** /**
* 對所有節點進行解析,確認每個節點對應的零件是否存在 * 對所有節點進行解析,確認每個節點對應的零件 ID
*
* 注意:此步驟只做靜態解析,不做遠端查找。
* 零件是否真的存在由 component-loader 在執行時決定(Service Binding / KV / URL)。
* *
* 優先序: * 優先序:
* 1. Input/Output 角色:自動標記,不需查找 * 1. Input/Output 角色:自動標記,componentId = 小寫節點名稱
* 2. 內建零件(BUILTIN_IDS):直接標記 found * 2. config[nodeName].component 已指定:使用 config 提供的 componentId
* 3. WASM_BUCKET 查找:確認 {componentId}/{componentId}.wasm 是否存在 * 3. 其他:componentId = 節點名稱(交給 component-loader 在執行時解析)
*/ */
export async function searchNodes( export function searchNodes(
parsed: ParsedTriplets, parsed: ParsedTriplets,
wasmBucket: R2Bucket, config?: Record<string, Record<string, unknown>>,
): Promise<SearchResult> { ): SearchResult {
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {}; const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
const missingNodes: string[] = [];
for (const nodeName of parsed.nodeNames) { for (const nodeName of parsed.nodeNames) {
const role = resolveNodeRole(nodeName, parsed); const role = resolveNodeRole(nodeName, parsed);
// 事件源節點(起始點):自動標記 Input,不查 WASM_BUCKET if (role === 'Input' || role === 'Output') {
if (role === 'Input') {
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role }; nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
continue; continue;
} }
// 輸出節點 const configComponent = config?.[nodeName]?.component as string | undefined;
if (role === 'Output') { const componentId = configComponent ?? nodeName;
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role }; nodeResults[nodeName] = { status: 'found', componentId, type: role };
continue;
} }
// 內建零件:直接標記 found return { nodeResults, missingNodes: [] };
if (BUILTIN_IDS.has(nodeName)) {
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
continue;
}
// WASM_BUCKET 查找:確認 {nodeName}/{nodeName}.wasm 是否存在
// 節點名稱即零件 canonical_id(如 "gmail"、"telegram"
const wasmKey = `${nodeName}/${nodeName}.wasm`;
const obj = await wasmBucket.head(wasmKey);
if (obj) {
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
} else {
nodeResults[nodeName] = { status: 'missing', type: role };
missingNodes.push(nodeName);
}
}
return { nodeResults, missingNodes };
} }
@@ -8,16 +8,13 @@ export async function resolveWebhookGraph(
body: Record<string, unknown>, body: Record<string, unknown>,
description: string, description: string,
env: Bindings, env: Bindings,
): Promise<{ resolvedGraph: Record<string, unknown>; error?: string; missingNodes?: string[] }> { ): Promise<{ resolvedGraph: Record<string, unknown>; error?: string }> {
// 路徑 Atriplets 格式 // 路徑 Atriplets 格式
if (Array.isArray(body.triplets) && body.triplets.length > 0) { if (Array.isArray(body.triplets) && body.triplets.length > 0) {
const parsed = parseTriplets(body.triplets as unknown[]); const parsed = parseTriplets(body.triplets as unknown[]);
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' }; if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET); const { nodeResults } = searchNodes(parsed);
if (missingNodes.length > 0) {
return { resolvedGraph: {}, error: `以下零件不存在:${missingNodes.join(', ')}。請執行 acr validate 確認所有零件已上傳。`, missingNodes };
}
const graphId = `webhook-${Date.now()}`; const graphId = `webhook-${Date.now()}`;
const graphName = description || `Webhook ${new Date().toISOString()}`; const graphName = description || `Webhook ${new Date().toISOString()}`;
@@ -28,6 +28,7 @@ export async function executeWebhookGraph(
graph: Record<string, unknown>, graph: Record<string, unknown>,
triggerContext: Record<string, unknown>, triggerContext: Record<string, unknown>,
token: string, token: string,
apiKey?: string,
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number }> { ): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number }> {
const parsed = graphSchema.safeParse(graph); const parsed = graphSchema.safeParse(graph);
if (!parsed.success) { if (!parsed.success) {
@@ -35,7 +36,7 @@ export async function executeWebhookGraph(
} }
const loader = createComponentLoader(env); const loader = createComponentLoader(env);
const executor = new GraphExecutor(loader, undefined, env); const executor = new GraphExecutor(loader, undefined, env, apiKey);
const start = Date.now(); const start = Date.now();
try { try {
+52 -10
View File
@@ -2,6 +2,7 @@
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types'; import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError } from './types'; import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError } from './types';
import { injectCredentials } from './actions/credential-injector'; import { injectCredentials } from './actions/credential-injector';
import { tryAuthDispatch } from './actions/auth-dispatcher';
export type ComponentLoader = (componentId: string) => Promise<ComponentRunner>; export type ComponentLoader = (componentId: string) => Promise<ComponentRunner>;
export type WorkflowLoader = (workflowId: string) => Promise<ExecutionGraph>; export type WorkflowLoader = (workflowId: string) => Promise<ExecutionGraph>;
@@ -13,12 +14,14 @@ export class GraphExecutor {
private loader: ComponentLoader; private loader: ComponentLoader;
private workflowLoader?: WorkflowLoader; private workflowLoader?: WorkflowLoader;
private env?: Bindings; private env?: Bindings;
private apiKey?: string;
public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>; public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>;
constructor(loader: ComponentLoader, workflowLoader?: WorkflowLoader, env?: Bindings) { constructor(loader: ComponentLoader, workflowLoader?: WorkflowLoader, env?: Bindings, apiKey?: string) {
this.loader = loader; this.loader = loader;
this.workflowLoader = workflowLoader; this.workflowLoader = workflowLoader;
this.env = env; this.env = env;
this.apiKey = apiKey;
} }
async execute( async execute(
@@ -106,15 +109,29 @@ export class GraphExecutor {
case 'Component': { case 'Component': {
if (!node.componentId) throw new Error(`節點 ${node.id} 缺少 componentId`); if (!node.componentId) throw new Error(`節點 ${node.id} 缺少 componentId`);
const runner = await this.loader(node.componentId); const runner = await this.loader(node.componentId);
const ctx = context as Record<string, unknown>;
// node.data 的 string 值支援 {{variable}} 替換(從 context 取值)
const resolvedData = interpolateData(node.data, ctx);
// 優先順序:node.data(靜態參數,如 pattern/sheet> context(全局參數) // 優先順序:node.data(靜態參數,如 pattern/sheet> context(全局參數)
let mergedContext: Record<string, unknown> = { let mergedContext: Record<string, unknown> = {
...(context as Record<string, unknown>), ...ctx,
...(node.data ?? {}), ...resolvedData,
}; };
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token // Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
if (this.env) { if (this.env) {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env); // 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP
// 命中才 return;否則 fallback 到舊 injectCredentialsPhase 1.9 會刪除)
if (this.apiKey) {
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
if (dispatched) {
mergedContext = dispatched;
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
}
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
}
} }
nodeInput = mergedContext; nodeInput = mergedContext;
@@ -207,18 +224,16 @@ export class GraphExecutor {
} }
case 'ON_SUCCESS': { case 'ON_SUCCESS': {
// 只在上游節點成功無 error)時執行 // 只在上游節點成功時執行:success !== false 且無 error key
const hasError = result && typeof result === 'object' && 'error' in (result as object); if (!isFailure(result)) {
if (!hasError) {
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore); result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
} }
break; break;
} }
case 'ON_FAIL': { case 'ON_FAIL': {
// 只在上游節點失敗(有 error)時執行,傳遞 error context // 只在上游節點失敗時執行:success === false 或有 error key
const hasError = result && typeof result === 'object' && 'error' in (result as object); if (isFailure(result)) {
if (hasError) {
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore); result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
} }
break; break;
@@ -295,6 +310,33 @@ export class GraphExecutor {
} }
} }
/** node.data 的 string 值支援 {{variable}} 替換,從 context 取值 */
function interpolateData(
data: Record<string, unknown> | undefined,
ctx: Record<string, unknown>,
): Record<string, unknown> {
if (!data) return {};
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) {
if (typeof v === 'string') {
result[k] = v.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const val = ctx[key];
return val !== undefined ? String(val) : `{{${key}}}`;
});
} else {
result[k] = v;
}
}
return result;
}
/** 判斷節點執行結果是否為失敗:success === false 或含有 error key */
function isFailure(result: unknown): boolean {
if (!result || typeof result !== 'object') return false;
const r = result as Record<string, unknown>;
return r['success'] === false || 'error' in r;
}
/** /**
* 安全條件評估(不使用 new Function * 安全條件評估(不使用 new Function
* 支援格式:ctx.key === value, ctx.key > value, ctx.keytruthy * 支援格式:ctx.key === value, ctx.key > value, ctx.keytruthy
+6
View File
@@ -12,6 +12,9 @@ import { webhooksCrudRouter } from './routes/webhooks-crud';
import { webhooksListRouter } from './routes/webhooks-list'; import { webhooksListRouter } from './routes/webhooks-list';
import { registerRouter } from './routes/register'; import { registerRouter } from './routes/register';
import { recipesRouter } from './routes/recipes'; import { recipesRouter } from './routes/recipes';
import { credentialsRouter } from './routes/credentials';
import { webhooksNamedRouter } from './routes/webhooks-named';
import { authRouter } from './routes/auth';
const app = new Hono<{ Bindings: Bindings }>(); const app = new Hono<{ Bindings: Bindings }>();
@@ -25,10 +28,13 @@ app.route('/', executeRouter);
app.route('/', cypherRouter); app.route('/', cypherRouter);
app.route('/', validateRouter); app.route('/', validateRouter);
app.route('/', webhooksRouter); app.route('/', webhooksRouter);
app.route('/', webhooksNamedRouter); // 必須在 webhooksCrudRouter 前(避免 /webhooks/:token 攔截 /webhooks/named
app.route('/', webhooksCrudRouter); app.route('/', webhooksCrudRouter);
app.route('/', webhooksListRouter); app.route('/', webhooksListRouter);
app.route('/', registerRouter); app.route('/', registerRouter);
app.route('/', recipesRouter); app.route('/', recipesRouter);
app.route('/', credentialsRouter);
app.route('/', authRouter);
// Worker 導出 // Worker 導出
export default app; export default app;
@@ -0,0 +1,565 @@
/**
* Auth Recipe Seeds
*
* 平台預建的 auth recipe 定義,部署時寫入 RECIPES KV。
* 新增服務 = 在此加一筆,不需改其他程式碼。
*
* KV key: auth_recipe:{service}
*/
import type { AuthRecipeDefinition } from '../routes/recipes';
const now = Date.now();
export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
// ── Static Key 類 ──────────────────────────────────────────────────────────
{
kind: 'auth_recipe',
service: 'notion',
version: 1,
primitive: 'static_key',
base_url: 'https://api.notion.com/v1',
display_name: 'Notion',
description: 'Notion API — 頁面、資料庫讀寫',
required_secrets: [
{
key: 'notion_token',
label: 'Internal Integration Token',
help: '至 https://www.notion.so/my-integrations 建立 Integration',
help_url: 'https://www.notion.so/my-integrations',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.notion_token}}',
'Notion-Version': '2022-06-28',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'slack',
version: 1,
primitive: 'static_key',
base_url: 'https://slack.com/api',
display_name: 'Slack',
description: 'Slack Bot API — 發訊息、查頻道',
required_secrets: [
{
key: 'slack_bot_token',
label: 'Bot User OAuth Token (xoxb-...)',
help: '至 https://api.slack.com/apps 建立 App,取得 Bot Token',
help_url: 'https://api.slack.com/apps',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.slack_bot_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'github',
version: 1,
primitive: 'static_key',
base_url: 'https://api.github.com',
display_name: 'GitHub',
description: 'GitHub REST API — repo、issue、PR 操作',
required_secrets: [
{
key: 'github_token',
label: 'Personal Access Token (classic 或 fine-grained)',
help: '至 https://github.com/settings/tokens 建立',
help_url: 'https://github.com/settings/tokens',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.github_token}}',
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'openai',
version: 1,
primitive: 'static_key',
base_url: 'https://api.openai.com/v1',
display_name: 'OpenAI',
description: 'OpenAI API — Chat Completions、Embeddings 等',
required_secrets: [
{
key: 'openai_api_key',
label: 'API Key (sk-...)',
help: '至 https://platform.openai.com/api-keys 建立',
help_url: 'https://platform.openai.com/api-keys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.openai_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'anthropic',
version: 1,
primitive: 'static_key',
base_url: 'https://api.anthropic.com/v1',
display_name: 'Anthropic (Claude)',
description: 'Anthropic API — Claude 模型呼叫',
required_secrets: [
{
key: 'anthropic_api_key',
label: 'API Key',
help: '至 https://console.anthropic.com/settings/keys 建立',
help_url: 'https://console.anthropic.com/settings/keys',
},
],
inject: {
header: {
'x-api-key': '{{secret.anthropic_api_key}}',
'anthropic-version': '2023-06-01',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'airtable',
version: 1,
primitive: 'static_key',
base_url: 'https://api.airtable.com/v0',
display_name: 'Airtable',
description: 'Airtable API — 讀寫 Base 資料',
required_secrets: [
{
key: 'airtable_token',
label: 'Personal Access Token',
help: '至 https://airtable.com/create/tokens 建立',
help_url: 'https://airtable.com/create/tokens',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.airtable_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'discord',
version: 1,
primitive: 'static_key',
base_url: 'https://discord.com/api/v10',
display_name: 'Discord',
description: 'Discord Bot API — 發訊息、管理伺服器',
required_secrets: [
{
key: 'discord_bot_token',
label: 'Bot Token',
help: '至 https://discord.com/developers/applications 建立 Bot,取得 Token',
help_url: 'https://discord.com/developers/applications',
},
],
inject: {
header: {
Authorization: 'Bot {{secret.discord_bot_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'stripe',
version: 1,
primitive: 'static_key',
base_url: 'https://api.stripe.com/v1',
display_name: 'Stripe',
description: 'Stripe API — 支付、客戶、訂閱管理',
required_secrets: [
{
key: 'stripe_secret_key',
label: 'Secret Key (sk_live_... 或 sk_test_...)',
help: '至 https://dashboard.stripe.com/apikeys 取得',
help_url: 'https://dashboard.stripe.com/apikeys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.stripe_secret_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'twilio',
version: 1,
primitive: 'static_key',
base_url: 'https://api.twilio.com/2010-04-01',
display_name: 'Twilio',
description: 'Twilio API — SMS、電話、WhatsApp',
required_secrets: [
{
key: 'twilio_account_sid',
label: 'Account SID',
help: '至 https://console.twilio.com/ 取得',
help_url: 'https://console.twilio.com/',
},
{
key: 'twilio_auth_token',
label: 'Auth Token',
help: '至 https://console.twilio.com/ 取得',
help_url: 'https://console.twilio.com/',
},
],
inject: {
header: {
Authorization: 'Basic {{secret.twilio_account_sid}}:{{secret.twilio_auth_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'sendgrid',
version: 1,
primitive: 'static_key',
base_url: 'https://api.sendgrid.com/v3',
display_name: 'SendGrid',
description: 'SendGrid Email API — 發送交易郵件',
required_secrets: [
{
key: 'sendgrid_api_key',
label: 'API Key (SG....)',
help: '至 https://app.sendgrid.com/settings/api_keys 建立',
help_url: 'https://app.sendgrid.com/settings/api_keys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.sendgrid_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'hubspot',
version: 1,
primitive: 'static_key',
base_url: 'https://api.hubapi.com',
display_name: 'HubSpot',
description: 'HubSpot CRM API — 聯絡人、公司、交易管理',
required_secrets: [
{
key: 'hubspot_token',
label: 'Private App Access Token',
help: '至 HubSpot Settings → Integrations → Private Apps 建立',
help_url: 'https://developers.hubspot.com/docs/api/private-apps',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.hubspot_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'linear',
version: 1,
primitive: 'static_key',
base_url: 'https://api.linear.app',
display_name: 'Linear',
description: 'Linear API — Issue、Project 管理',
required_secrets: [
{
key: 'linear_api_key',
label: 'Personal API Key',
help: '至 https://linear.app/settings/api 建立',
help_url: 'https://linear.app/settings/api',
},
],
inject: {
header: {
Authorization: '{{secret.linear_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'shopify',
version: 1,
primitive: 'static_key',
base_url: 'https://{{secret.shopify_store}}.myshopify.com/admin/api/2024-01',
display_name: 'Shopify',
description: 'Shopify Admin API — 訂單、商品、客戶管理',
required_secrets: [
{
key: 'shopify_access_token',
label: 'Admin API Access Token',
help: '至 Shopify Admin → Apps → App and sales channel settings → Private apps',
help_url: 'https://shopify.dev/docs/apps/auth/admin-app-access-tokens',
},
{
key: 'shopify_store',
label: 'Store subdomain(不含 .myshopify.com',
help: '例如 my-store(對應 my-store.myshopify.com',
},
],
inject: {
header: {
'X-Shopify-Access-Token': '{{secret.shopify_access_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'resend',
version: 1,
primitive: 'static_key',
base_url: 'https://api.resend.com',
display_name: 'Resend',
description: 'Resend Email API — 發送交易郵件',
required_secrets: [
{
key: 'resend_api_key',
label: 'API Key (re_...)',
help: '至 https://resend.com/api-keys 建立',
help_url: 'https://resend.com/api-keys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.resend_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'supabase',
version: 1,
primitive: 'static_key',
base_url: 'https://{{secret.supabase_project_ref}}.supabase.co/rest/v1',
display_name: 'Supabase',
description: 'Supabase REST API — 資料庫讀寫',
required_secrets: [
{
key: 'supabase_service_key',
label: 'Service Role Key (eyJ...)',
help: '至 Supabase Project Settings → API → service_role key',
help_url: 'https://supabase.com/dashboard',
},
{
key: 'supabase_project_ref',
label: 'Project Reference IDURL 中的 xxx.supabase.co 的 xxx',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.supabase_service_key}}',
apikey: '{{secret.supabase_service_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'typeform',
version: 1,
primitive: 'static_key',
base_url: 'https://api.typeform.com',
display_name: 'Typeform',
description: 'Typeform API — 表單、問卷回應讀取',
required_secrets: [
{
key: 'typeform_token',
label: 'Personal Access Token',
help: '至 https://admin.typeform.com/account#/section/tokens 建立',
help_url: 'https://developer.typeform.com/get-started/',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.typeform_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'jira',
version: 1,
primitive: 'static_key',
base_url: 'https://{{secret.jira_domain}}.atlassian.net/rest/api/3',
display_name: 'Jira',
description: 'Jira API — Issue、Sprint、Project 管理',
required_secrets: [
{
key: 'jira_api_token',
label: 'API Token',
help: '至 https://id.atlassian.com/manage-profile/security/api-tokens 建立',
help_url: 'https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/',
},
{
key: 'jira_email',
label: '你的 Atlassian 帳號 Email',
},
{
key: 'jira_domain',
label: 'Jira 子網域(xxx.atlassian.net 的 xxx',
},
],
inject: {
header: {
Authorization: 'Basic {{secret.jira_email}}:{{secret.jira_api_token}}',
Accept: 'application/json',
},
},
created_at: now,
updated_at: now,
},
// ── Service Account 類(Google 家族,共用同一份 service_account_json)────────
{
kind: 'auth_recipe',
service: 'google_sheets_sa',
version: 1,
primitive: 'service_account',
service_account_kind: 'google_jwt',
base_url: 'https://sheets.googleapis.com/v4',
display_name: 'Google Sheets (Service Account)',
description: 'Google Sheets API — 試算表讀寫(使用 Service Account',
token_exchange: {
endpoint: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
},
required_secrets: [
{
key: 'google_service_account',
label: 'Service Account JSON(整份貼上)',
type: 'json_blob',
help: '至 GCP Console → IAM → Service Accounts → Keys → Add Key → JSON,下載後整份貼入',
help_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
},
],
inject: {
header: {
Authorization: 'Bearer {{runtime.access_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'google_gmail_sa',
version: 1,
primitive: 'service_account',
service_account_kind: 'google_jwt',
base_url: 'https://gmail.googleapis.com/gmail/v1',
display_name: 'Gmail (Service Account)',
description: 'Gmail API — 發送郵件(使用 Service Account + Domain-Wide Delegation',
token_exchange: {
endpoint: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/gmail.send'],
},
required_secrets: [
{
key: 'google_service_account',
label: 'Service Account JSON(整份貼上)',
type: 'json_blob',
help: '需要 Domain-Wide Delegation,至 GCP Console → IAM → Service Accounts 設定',
help_url: 'https://developers.google.com/workspace/guides/create-credentials#service-account',
},
],
inject: {
header: {
Authorization: 'Bearer {{runtime.access_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'google_drive_sa',
version: 1,
primitive: 'service_account',
service_account_kind: 'google_jwt',
base_url: 'https://www.googleapis.com/drive/v3',
display_name: 'Google Drive (Service Account)',
description: 'Google Drive API — 檔案上傳、下載、管理(使用 Service Account',
token_exchange: {
endpoint: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive'],
},
required_secrets: [
{
key: 'google_service_account',
label: 'Service Account JSON(整份貼上)',
type: 'json_blob',
help: '至 GCP Console → IAM → Service Accounts → Keys → Add Key → JSON',
help_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
},
],
inject: {
header: {
Authorization: 'Bearer {{runtime.access_token}}',
},
},
created_at: now,
updated_at: now,
},
];
+414
View File
@@ -0,0 +1,414 @@
// arcrun OAuth 登入路由
// GET /auth/google/start → redirect to Google OAuth
// GET /auth/github/start → redirect to GitHub OAuth
// GET /auth/callback → exchange code, create session
// POST /auth/logout → clear session cookie
// GET /me → current user info
// PUT /me/api-key/rotate → generate new api key
// DELETE /me/api-key → revoke api key
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const authRouter = new Hono<{ Bindings: Bindings }>();
// ─── Types ────────────────────────────────────────────────────────────────────
type UserRecord = {
email: string;
display_name: string;
avatar_url?: string;
api_key: string;
provider: 'google' | 'github';
provider_id: string;
created_at: string;
revoked?: boolean;
};
type SessionRecord = {
user_key: string; // "user:{provider}:{provider_id}"
api_key: string;
email: string;
expires_at: number; // unix timestamp ms
};
type OAuthStateRecord = {
provider: 'google' | 'github';
redirect_back: string;
created_at: number;
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getLandingOrigin(c: { req: { raw: Request } }): string {
const origin = c.req.raw.headers.get('origin');
// 允許的 landing origins
const allowed = ['https://arcrun.dev', 'https://www.arcrun.dev'];
if (origin && allowed.includes(origin)) return origin;
return 'https://arcrun.dev';
}
/** 產生 API KeyHMAC-SHA256 of email,與 /register 相同邏輯) */
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
/** 產生隨機 token(用於 session ID 和 state */
function randomToken(bytes = 32): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
/** 從 Cookie header 取 session ID */
function getSessionId(req: Request): string | null {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/arcrun_session=([a-f0-9]+)/);
return match ? match[1] : null;
}
/** 從 Request 取 API KeyX-Arcrun-API-Key header 或 Authorization: Bearer */
function getApiKeyFromRequest(req: Request): string | null {
const direct = req.headers.get('x-arcrun-api-key');
if (direct) return direct;
const auth = req.headers.get('authorization') ?? '';
const match = auth.match(/^Bearer\s+(ak_\S+)/i);
return match ? match[1] : null;
}
/** 驗證 session → 回傳 user record,或 null */
async function resolveSession(c: { req: { raw: Request }; env: Bindings }): Promise<UserRecord | null> {
const sessId = getSessionId(c.req.raw);
if (sessId) {
const sess = await c.env.SESSIONS_KV.get<SessionRecord>(`sess:${sessId}`, 'json');
if (sess && sess.expires_at > Date.now()) {
const user = await c.env.USERS_KV.get<UserRecord>(sess.user_key, 'json');
if (user && !user.revoked) return user;
}
}
// Fallback: API Key header
const apiKey = getApiKeyFromRequest(c.req.raw);
if (apiKey) {
// 掃描 USERS_KV by api_key 太慢;改用 reverse index: apikey:{ak_...} → user_key
const userKey = await c.env.USERS_KV.get(`apikey:${apiKey}`);
if (userKey) {
const user = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
if (user && !user.revoked && user.api_key === apiKey) return user;
}
}
return null;
}
// ─── Google OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/google/start', async (c) => {
const clientId = c.env.GOOGLE_CLIENT_ID;
if (!clientId) return c.json({ error: 'Google OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'google',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
// state TTL = 10 minutes
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email',
state,
access_type: 'offline',
prompt: 'select_account',
});
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
});
// ─── GitHub OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/github/start', async (c) => {
const clientId = c.env.GITHUB_CLIENT_ID;
if (!clientId) return c.json({ error: 'GitHub OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'github',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email',
state,
});
return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
});
// ─── OAuth Callback ───────────────────────────────────────────────────────────
authRouter.get('/auth/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const error = c.req.query('error');
const landingOrigin = getLandingOrigin(c);
if (error || !code || !state) {
return Response.redirect(`${landingOrigin}/login?error=${encodeURIComponent(error ?? 'cancelled')}`, 302);
}
// Validate state
const stateRecord = await c.env.SESSIONS_KV.get<OAuthStateRecord>(`state:${state}`, 'json');
if (!stateRecord) {
return Response.redirect(`${landingOrigin}/login?error=invalid_state`, 302);
}
await c.env.SESSIONS_KV.delete(`state:${state}`);
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
try {
let email: string;
let displayName: string;
let avatarUrl: string | undefined;
let providerId: string;
const provider = stateRecord.provider;
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
if (provider === 'google') {
// Exchange code for token
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenRes.ok) throw new Error('google token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string };
// Get user info
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) throw new Error('google userinfo failed');
const userInfo = await userRes.json() as {
sub: string; email: string; name: string; picture?: string;
};
email = userInfo.email.toLowerCase();
displayName = userInfo.name;
avatarUrl = userInfo.picture;
providerId = userInfo.sub;
} else {
// GitHub: exchange code for token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
code,
client_id: c.env.GITHUB_CLIENT_ID ?? '',
client_secret: c.env.GITHUB_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) throw new Error('github token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string };
// Get user info
const userRes = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (!userRes.ok) throw new Error('github user fetch failed');
const userInfo = await userRes.json() as {
id: number; login: string; name?: string; avatar_url?: string; email?: string;
};
// GitHub email might be null if private; fetch emails list
let ghEmail = userInfo.email ?? '';
if (!ghEmail) {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (emailsRes.ok) {
const emails = await emailsRes.json() as { email: string; primary: boolean; verified: boolean }[];
const primary = emails.find(e => e.primary && e.verified);
ghEmail = primary?.email ?? emails[0]?.email ?? '';
}
}
if (!ghEmail) throw new Error('github email not available');
email = ghEmail.toLowerCase();
displayName = userInfo.name ?? userInfo.login;
avatarUrl = userInfo.avatar_url;
providerId = String(userInfo.id);
}
// Upsert user record
const userKey = `user:${provider}:${providerId}`;
const existing = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
let apiKey: string;
if (existing && !existing.revoked) {
// Existing user — keep their api key
apiKey = existing.api_key;
// Update display info
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
} else {
// New user — generate api key (same HMAC logic as /register)
apiKey = await generateApiKey(email, encryptionKey);
const newUser: UserRecord = {
email, display_name: displayName, avatar_url: avatarUrl,
api_key: apiKey, provider, provider_id: providerId,
created_at: new Date().toISOString(),
};
await c.env.USERS_KV.put(userKey, JSON.stringify(newUser));
// Reverse index for API-Key-based auth
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
}
// Create session (TTL 7 days)
const sessionId = randomToken(32);
const session: SessionRecord = {
user_key: userKey,
api_key: apiKey,
email,
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000,
};
await c.env.SESSIONS_KV.put(`sess:${sessionId}`, JSON.stringify(session), {
expirationTtl: 7 * 24 * 60 * 60,
});
const redirectBack = stateRecord.redirect_back.startsWith('/') ? stateRecord.redirect_back : '/dashboard';
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}${redirectBack}`,
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`,
},
});
} catch (err) {
console.error('[auth/callback]', err);
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────────
authRouter.post('/auth/logout', async (c) => {
const sessId = getSessionId(c.req.raw);
if (sessId) {
await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
}
const landingOrigin = getLandingOrigin(c);
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}/`,
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
},
});
});
// ─── /me ──────────────────────────────────────────────────────────────────────
authRouter.get('/me', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
return c.json({
email: user.email,
display_name: user.display_name,
avatar_url: user.avatar_url,
api_key: user.api_key,
provider: user.provider,
created_at: user.created_at,
});
});
// ─── Rotate API Key ───────────────────────────────────────────────────────────
authRouter.put('/me/api-key/rotate', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
// Generate new random key (not HMAC — rotated keys are random)
const newRaw = randomToken(24);
const newKey = 'ak_' + newRaw;
const oldKey = user.api_key;
const userKey = `user:${user.provider}:${user.provider_id}`;
const updated: UserRecord = { ...user, api_key: newKey };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
// Update reverse index
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
// Invalidate all current sessions for this user (simple: sessions will re-auth on next request)
// (Full invalidation would require listing all sessions, skip for now)
return c.json({
success: true,
api_key: newKey,
message: 'API Key rotated. Your existing workflow credentials are still stored under the old key namespace.',
});
});
// ─── Revoke API Key ───────────────────────────────────────────────────────────
authRouter.delete('/me/api-key', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
const userKey = `user:${user.provider}:${user.provider_id}`;
const revoked: UserRecord = { ...user, revoked: true };
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
// Clear session cookie
const sessId = getSessionId(c.req.raw);
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
return new Response(JSON.stringify({ success: true, message: 'API Key revoked.' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
},
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* Credentials API — 多租戶 credential 管理
*
* POST /credentials
* Body: { name: string, encrypted: string, iv: string }
* Header: X-Arcrun-API-Key
* → 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV
*
* DELETE /credentials/:name
* Header: X-Arcrun-API-Key
* → 刪除 {api_key}:cred:{name}
*
* GET /credentials
* Header: X-Arcrun-API-Key
* → 列出當前 api_key 下所有 credential 名稱(不含加密值)
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
// POST /credentials — 上傳加密 credential
credentialsRouter.post('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const body = await c.req.json().catch(() => null) as {
name?: string;
encrypted?: string;
iv?: string;
} | null;
if (!body?.name || !body.encrypted || !body.iv) {
return c.json({ error: '缺少必要欄位:name, encrypted, iv' }, 400);
}
const name = body.name.trim();
if (!/^\w+$/.test(name)) {
return c.json({ error: 'credential name 只能包含英文字母、數字和底線' }, 400);
}
const kvKey = `${apiKey}:cred:${name}`;
const record = JSON.stringify({ encrypted: body.encrypted, iv: body.iv });
await c.env.CREDENTIALS_KV.put(kvKey, record);
return c.json({ success: true, name });
});
// DELETE /credentials/:name — 刪除 credential
credentialsRouter.delete('/credentials/:name', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const name = c.req.param('name');
const kvKey = `${apiKey}:cred:${name}`;
await c.env.CREDENTIALS_KV.delete(kvKey);
return c.json({ success: true, name });
});
// GET /credentials — 列出 credential 名稱(不含值)
credentialsRouter.get('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const prefix = `${apiKey}:cred:`;
const list = await c.env.CREDENTIALS_KV.list({ prefix });
const names = list.keys.map(k => k.name.slice(prefix.length));
return c.json({ credentials: names });
});
+3
View File
@@ -57,6 +57,8 @@ cypherRouter.post('/cypher/execute', async (c) => {
// 版本號格式:execute-v1-20260327-143022 // 版本號格式:execute-v1-20260327-143022
const versionId = `execute-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; const versionId = `execute-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
try { try {
const result = await handleCypherExecute( const result = await handleCypherExecute(
body.triplets as unknown[], body.triplets as unknown[],
@@ -66,6 +68,7 @@ cypherRouter.post('/cypher/execute', async (c) => {
body.config, body.config,
c.env, c.env,
(p) => c.executionCtx.waitUntil(p), (p) => c.executionCtx.waitUntil(p),
apiKey,
); );
// 包裝成開發友善格式(execute 成功時) // 包裝成開發友善格式(execute 成功時)
const response = { const response = {
+115
View File
@@ -123,3 +123,118 @@ export async function resolveRecipe(
// 直接用 canonical_id // 直接用 canonical_id
return kv.get(`recipe:${id}`, 'json'); return kv.get(`recipe:${id}`, 'json');
} }
// ── Auth Recipe ────────────────────────────────────────────────────────────────
export type AuthPrimitive = 'static_key' | 'oauth2' | 'service_account' | 'mtls';
export interface SecretRequirement {
key: string; // CREDENTIALS_KV 的名稱(e.g. "notion_token"
label: string; // CLI/UI 顯示(e.g. "Internal Integration Token"
type?: 'string' | 'json_blob'; // default: string
help?: string;
help_url?: string;
optional?: boolean;
}
export interface AuthInjectSpec {
header?: Record<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
query?: Record<string, string>;
body?: Record<string, string>;
}
export interface AuthRecipeDefinition {
kind: 'auth_recipe';
service: string; // canonical_ide.g. "notion"
version: number;
primitive: AuthPrimitive;
base_url: string;
display_name?: string;
description?: string;
// service_account 專用
service_account_kind?: 'google_jwt';
token_exchange?: {
endpoint: string;
scopes: string[];
};
required_secrets: SecretRequirement[];
inject: AuthInjectSpec;
created_at: number;
updated_at: number;
}
/** 查 auth recipeKV key: auth_recipe:{service}*/
export async function resolveAuthRecipe(
service: string,
kv: KVNamespace,
): Promise<AuthRecipeDefinition | null> {
return kv.get(`auth_recipe:${service}`, 'json');
}
// POST /auth-recipes — 新增或更新 auth recipe
recipesRouter.post('/auth-recipes', async (c) => {
let body: Partial<AuthRecipeDefinition>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
const service = (body.service ?? '').trim().toLowerCase();
if (!service) return c.json({ success: false, error: 'service 必填' }, 400);
if (!body.primitive) return c.json({ success: false, error: 'primitive 必填' }, 400);
if (!body.base_url) return c.json({ success: false, error: 'base_url 必填' }, 400);
if (!body.required_secrets?.length) return c.json({ success: false, error: 'required_secrets 必填' }, 400);
if (!body.inject) return c.json({ success: false, error: 'inject 必填' }, 400);
const now = Date.now();
const existing = await c.env.RECIPES.get(`auth_recipe:${service}`, 'json') as AuthRecipeDefinition | null;
const recipe: AuthRecipeDefinition = {
kind: 'auth_recipe',
service,
version: body.version ?? 1,
primitive: body.primitive,
base_url: body.base_url,
display_name: body.display_name,
description: body.description,
service_account_kind: body.service_account_kind,
token_exchange: body.token_exchange,
required_secrets: body.required_secrets,
inject: body.inject,
created_at: existing?.created_at ?? now,
updated_at: now,
};
await c.env.RECIPES.put(`auth_recipe:${service}`, JSON.stringify(recipe));
return c.json({ success: true, recipe });
});
// GET /auth-recipes — 列出所有 auth recipe
recipesRouter.get('/auth-recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'auth_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 });
});
// GET /auth-recipes/:service — 讀取單一 auth recipe
recipesRouter.get('/auth-recipes/:service', async (c) => {
const service = c.req.param('service');
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
return c.json({ success: true, recipe });
});
// DELETE /auth-recipes/:service — 刪除 auth recipe
recipesRouter.delete('/auth-recipes/:service', async (c) => {
const service = c.req.param('service');
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
await c.env.RECIPES.delete(`auth_recipe:${service}`);
return c.json({ success: true, deleted: service });
});
+1
View File
@@ -39,6 +39,7 @@ registerRouter.post('/register', async (c) => {
return c.json({ return c.json({
success: true, success: true,
api_key: apiKey, api_key: apiKey,
encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential
email, email,
message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。', message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。',
}); });
@@ -0,0 +1,167 @@
/**
* Named Webhookacr push 使用)
*
* POST /webhooks/named
* Header: X-Arcrun-API-Key
* Body: { name, graph, config?, description? }
* → 以 {api_key}:wf:{name} 存入 WEBHOOKS KV
* → 回傳 webhook_url
*
* POST /webhooks/named/:name/trigger
* Header: X-Arcrun-API-Key
* Body: 任意 JSON(作為 trigger context
* → 以 {api_key}:wf:{name} 讀取執行圖,執行後回傳結果
*
* GET /webhooks/named
* Header: X-Arcrun-API-Key
* → 列出當前 api_key 下所有 named webhook
*
* DELETE /webhooks/named/:name
* Header: X-Arcrun-API-Key
* → 刪除指定 workflow
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { executeWebhookGraph } from '../actions/webhook-handlers';
import { writeExecutionVerdict } from '../actions/execution-logger';
import type { GraphNode } from '../types';
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
type NamedWorkflowRecord = {
name: string;
graph: Record<string, unknown>;
config?: Record<string, unknown>;
description: string;
created_at: string;
};
function kvKey(apiKey: string, name: string): string {
return `${apiKey}:wf:${name}`;
}
// POST /webhooks/named — 部署(acr push 呼叫)
webhooksNamedRouter.post('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const body = await c.req.json().catch(() => null) as {
name?: string;
graph?: Record<string, unknown>;
config?: Record<string, unknown>;
description?: string;
} | null;
if (!body?.name || !body.graph) {
return c.json({ error: '缺少必要欄位:name, graph' }, 400);
}
const name = body.name.trim();
if (!/^[\w-]+$/.test(name)) {
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
}
const record: NamedWorkflowRecord = {
name,
graph: body.graph,
config: body.config,
description: typeof body.description === 'string' ? body.description : '',
created_at: new Date().toISOString(),
};
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
const baseUrl = new URL(c.req.url).origin;
return c.json({
name,
webhook_url: `${baseUrl}/webhooks/named/${name}/trigger`,
description: record.description,
created_at: record.created_at,
}, 201);
});
// POST /webhooks/named/:name/trigger — 觸發執行
webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const name = c.req.param('name');
const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
if (!raw) {
return c.json({ error: `找不到 workflow "${name}",請先執行 acr push` }, 404);
}
let record: NamedWorkflowRecord;
try {
record = JSON.parse(raw) as NamedWorkflowRecord;
} catch {
return c.json({ error: 'workflow 定義損毀' }, 500);
}
let triggerContext: Record<string, unknown> = {};
try {
const body = await c.req.json().catch(() => null);
if (body && typeof body === 'object') {
triggerContext = body as Record<string, unknown>;
}
} catch {
// 無 body 時使用空 context
}
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey);
const graph = record.graph as { id?: string; nodes?: unknown[] };
const workflowId = graph.id ?? name;
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as GraphNode[]) : [];
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
);
return c.json(result, result.success ? 200 : 500);
});
// GET /webhooks/named — 列出當前 api_key 下所有 workflow
webhooksNamedRouter.get('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const prefix = `${apiKey}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = list.keys.map(k => {
const name = k.name.slice(prefix.length);
return { name };
});
const baseUrl = new URL(c.req.url).origin;
const result = workflows.map(w => ({
name: w.name,
webhook_url: `${baseUrl}/webhooks/named/${w.name}/trigger`,
}));
return c.json({ workflows: result, total: result.length });
});
// DELETE /webhooks/named/:name — 刪除 workflow
webhooksNamedRouter.delete('/webhooks/named/:name', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const name = c.req.param('name');
const existing = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
if (!existing) {
return c.json({ error: `找不到 workflow "${name}"` }, 404);
}
await c.env.WEBHOOKS.delete(kvKey(apiKey, name));
return c.json({ deleted: true, name });
});
+3 -6
View File
@@ -21,11 +21,7 @@ webhooksRouter.post('/webhooks', async (c) => {
const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env); const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env);
if (resolved.error) { if (resolved.error) {
const statusCode = resolved.missingNodes ? 422 : 400; return c.json({ error: resolved.error }, 400);
return c.json(
{ error: resolved.error, ...(resolved.missingNodes && { missing: resolved.missingNodes }) },
statusCode,
);
} }
const token = generateToken(); const token = generateToken();
@@ -69,7 +65,8 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
// 無 body 時使用空 context // 無 body 時使用空 context
} }
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token); const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token, apiKey);
// fire-and-forget analytics(不阻擋回應) // fire-and-forget analytics(不阻擋回應)
const graph = record.graph as { id?: string; nodes?: unknown[] }; const graph = record.graph as { id?: string; nodes?: unknown[] };
+10
View File
@@ -34,12 +34,22 @@ export type Bindings = {
ANALYTICS_KV: KVNamespace; ANALYTICS_KV: KVNamespace;
// R2 BucketWASM 零件二進位 // R2 BucketWASM 零件二進位
WASM_BUCKET: R2Bucket; WASM_BUCKET: R2Bucket;
// UsersOAuth 登入用戶帳號(key = user:{provider}:{provider_id}
USERS_KV: KVNamespace;
// Sessions:登入 sessionkey = sess:{session_id}TTL 7d
SESSIONS_KV: KVNamespace;
// Workers AI // Workers AI
AI: Ai; AI: Ai;
// 環境變數 // 環境變數
ENVIRONMENT: string; ENVIRONMENT: string;
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES keywrangler secret ENCRYPTION_KEY: string; // hex-encoded 256-bit AES keywrangler secret
MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true" MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true"
// OAuth Secretswrangler secret
GOOGLE_CLIENT_ID?: string;
GOOGLE_CLIENT_SECRET?: string;
GITHUB_CLIENT_ID?: string;
GITHUB_CLIENT_SECRET?: string;
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
}; };
// 圖結構定義 // 圖結構定義
+8
View File
@@ -23,6 +23,14 @@ id = "a43b7997c8e54a34886c2995a853c720"
binding = "RECIPES" binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0" id = "9cf9db905c6241f78503199e58b2ffe0"
[[kv_namespaces]]
binding = "USERS_KV"
id = "25bef01d079148919578894434d58c4d"
[[kv_namespaces]]
binding = "SESSIONS_KV"
id = "455d0505c7534883a4d4985ab8295857"
[[r2_buckets]] [[r2_buckets]]
binding = "WASM_BUCKET" binding = "WASM_BUCKET"
bucket_name = "arcrun-wasm" bucket_name = "arcrun-wasm"