// http_request — 發送任意 HTTP 請求,回傳 status + body // 透過 host function 發出 HTTP,.wasm 本身不含網路 syscall // //go:build tinygo package main import ( "encoding/json" "io" "os" "strconv" "unsafe" ) // host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組數字。 // 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92:以前這種情況會被說成 "HTTP request failed") const hostTooLarge uint32 = 3 // host function 宣告(由 WASI shim 注入) // //go:wasmimport u6u http_request func hostHttpRequest( urlPtr uintptr, urlLen uint32, methodPtr uintptr, methodLen uint32, headersPtr uintptr, headersLen uint32, bodyPtr uintptr, bodyLen uint32, outPtr uintptr, outLenPtr uintptr, ) uint32 type Input struct { URL string `json:"url"` Method string `json:"method"` Headers map[string]string `json:"headers"` Body string `json:"body"` // 模式 A:直接 string body BodyJSON map[string]interface{} `json:"body_json"` // 模式 B:物件,內部 stringify(避免 yaml 端要自己組 JSON 字串) } // dummy byte for safe zero-length unsafe.Pointer operations var dummy [1]byte // safePtr returns a valid pointer for an empty-or-nonempty byte slice. // TinyGo panics with "index out of range" when taking &b[0] on empty b. func safePtr(b []byte) (uintptr, uint32) { if len(b) == 0 { return uintptr(unsafe.Pointer(&dummy[0])), 0 } return uintptr(unsafe.Pointer(&b[0])), uint32(len(b)) } func main() { raw, err := io.ReadAll(os.Stdin) if err != nil { writeError("failed to read stdin: " + err.Error()) return } var input Input if err := json.Unmarshal(raw, &input); err != nil { writeError("invalid input JSON: " + err.Error()) return } if input.URL == "" { writeError("url 必填") return } method := input.Method if method == "" { method = "GET" } headersJSON := "{}" if len(input.Headers) > 0 { b, _ := json.Marshal(input.Headers) headersJSON = string(b) } // body 來源優先順序:body_json(物件 → JSON 字串)> body(直接 string) bodyStr := input.Body if input.BodyJSON != nil { b, err := json.Marshal(input.BodyJSON) if err == nil { bodyStr = string(b) } } urlBytes := []byte(input.URL) methodBytes := []byte(method) headersBytes := []byte(headersJSON) bodyBytes := []byte(bodyStr) outBuf := make([]byte, 65536) // 64KB output buffer // 容量握手(Arcrun#92):呼叫前先把緩衝區大小告訴 host。 // host(cypher-executor/src/lib/wasi-shim.ts 的 writeOut)拿這個值當上限—— // 塞不下時不會硬寫爆這塊記憶體,而是改寫一段「回應太大 + 實際/上限大小 + 該怎麼辦」 // 的 error envelope 回來,由下面既有的 parsed["error"] 判定鏈原樣交給使用者。 outLen := uint32(len(outBuf)) urlPtr, urlLen := safePtr(urlBytes) methodPtr, methodLen := safePtr(methodBytes) headersPtr, headersLen := safePtr(headersBytes) bodyPtr, bodyLen := safePtr(bodyBytes) result := hostHttpRequest( urlPtr, urlLen, methodPtr, methodLen, headersPtr, headersLen, bodyPtr, bodyLen, uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), ) // host function 回傳碼(定義在 wasi-shim.ts):0=成功 1=host 端錯誤 3=回應塞不下緩衝區 if result == hostTooLarge { // 走到這裡=連「回應太大」的說明本身都塞不進緩衝區(極端情況), // 所以零件自己講。訊息一樣要講清楚真因,不能退回 "HTTP request failed"。 writeError("回應太大,裝不下:對方的回應超過這個零件單次能接收的 64 KB 上限。" + "這不是連線失敗,資料也沒有被截掉一半。" + "做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試。") return } if result != 0 { writeError("沒有拿到回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是你的 workflow 參數寫錯。") return } responseStr := string(outBuf[:outLen]) // 偵測 JSON `{"error":"..."}` 模式視為失敗。 // 2026-06-09 修架構債:host function(.component-builds/http_request/src/index.ts)現在對非 2xx // 回 envelope `{"error":"HTTP ","status":,"body":<原文>}`——故此處 parsed["error"] // 能正確 catch 所有 4xx/5xx(含 Notion 401 那種 body 用 {"object":"error"} 不帶 error key 的)。 // 之前 host fn 只回 body 原文丟掉 status → 401 被判 success(系統假綠根因,已修)。 // 註:claude_api/kbdb_upsert_block/km_writer 已同樣修(非 2xx 回 error envelope)。 // auth_service_account 不套此 envelope——它 main.go 自己解析 OAuth token 回應的 // {access_token,error,error_description},access_token 空即視為失敗,已有自己的判定, // 套 envelope 反而會丟掉 error_description 破壞 token exchange 錯誤處理。 // 待辦:4 份 inline host fn 最好抽成共用 helper(dedup,目前複製貼上)。 var parsed map[string]interface{} if err := json.Unmarshal([]byte(responseStr), &parsed); err == nil { if errVal, ok := parsed["error"]; ok && errVal != nil { out, _ := json.Marshal(map[string]interface{}{ "success": false, "error": errVal, "data": map[string]interface{}{"body": responseStr}, }) os.Stdout.Write(out) return } } out, _ := json.Marshal(map[string]interface{}{ "success": true, "data": map[string]interface{}{"body": responseStr}, }) os.Stdout.Write(out) } func writeError(msg string) { out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg}) os.Stdout.Write(out) }