ffi/curl: blocking POST with header list and response capture

Phase 0 binding per PHASE0.md §6. M.post(url, body, headers, timeout_ms)
uses CURLOPT_{URL, POST, POSTFIELDS, HTTPHEADER, WRITEFUNCTION, NOSIGNAL,
TIMEOUT_MS, USERAGENT} on a fresh easy handle, capturing the response
into a Lua string via a closure-based WRITEFUNCTION callback.

curl_easy_setopt is variadic; LuaJIT's variadic FFI dispatch needs
ffi.new() per argument otherwise. Pre-cast to three concrete signatures
(long / void* / const char*) bypasses that — cleaner and matches the
lua-curl idiom.

Robust loader: tries `curl`, `curl.so.4`, `curl-gnutls.so.4` so a
runtime-only host (no libcurl-dev installed) just works. Same idiom
as ffi/readline.

Smoke against a local nc listener: request was correctly framed
(POST path, Content-Type + X-Test headers, Content-Length matches
JSON body length) and the canned response was captured into the
returned Lua string.

SSE streaming for Phase 1 reuses this same WRITEFUNCTION hook —
chunks arrive incrementally, the closure consumes them as they come.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:54:36 +00:00
parent c9116c9bbf
commit 5fd7c7ac63
+102 -3
View File
@@ -1,16 +1,115 @@
-- ffi/curl.lua — libcurl easy interface binding.
-- Phase 0: blocking POST. Phase 1: SSE streaming via WRITEFUNCTION callback.
-- Phase 0: blocking POST with header list and response capture into Lua string.
-- Phase 1: SSE streaming via streaming WRITEFUNCTION (this same callback hook).
-- See docs/PHASE0.md §6.
local ffi = require("ffi")
ffi.cdef[[
typedef void CURL;
struct curl_slist {
char *data;
struct curl_slist *next;
};
CURL *curl_easy_init(void);
void curl_easy_cleanup(CURL *handle);
int curl_easy_setopt(CURL *handle, int option, ...);
int curl_easy_perform(CURL *handle);
const char *curl_easy_strerror(int code);
struct curl_slist *curl_slist_append(struct curl_slist *list, const char *string);
void curl_slist_free_all(struct curl_slist *list);
int curl_easy_setopt(CURL *handle, int option, ...);
]]
-- libcurl-dev's unversioned `libcurl.so` symlink isn't assumed; fall back to
-- versioned sonames so a runtime-only host (Debian without -dev) just works.
local function load_curl()
local errs = {}
for _, name in ipairs({"curl", "curl.so.4", "curl-gnutls.so.4"}) do
local ok, lib = pcall(ffi.load, name)
if ok then return lib end
errs[#errs+1] = name .. ": " .. tostring(lib)
end
error("libcurl not loadable: " .. table.concat(errs, "; "))
end
local C = load_curl()
-- CURLoption codes from curl/curl.h. The bases are:
-- CURLOPTTYPE_LONG = 0
-- CURLOPTTYPE_OBJECTPOINT = 10000
-- CURLOPTTYPE_FUNCTIONPOINT = 20000
local OPT = {
URL = 10002,
POST = 47,
POSTFIELDS = 10015,
HTTPHEADER = 10023,
WRITEFUNCTION = 20011,
NOSIGNAL = 99,
TIMEOUT_MS = 155,
USERAGENT = 10018,
}
-- Variadic FFI calls demand explicit per-argument types. Pre-cast setopt to
-- the three concrete signatures Phase 0 needs; bypasses libffi-flavoured
-- variadic dispatch entirely.
local setopt_str = ffi.cast("int(*)(void*, int, const char*)", C.curl_easy_setopt)
local setopt_long = ffi.cast("int(*)(void*, int, long)", C.curl_easy_setopt)
local setopt_ptr = ffi.cast("int(*)(void*, int, void*)", C.curl_easy_setopt)
local M = {}
-- Phase 0 stubs; full binding lands with broker.chat() implementation.
-- POST `body` to `url` with `headers` (list of "Name: value" strings) and an
-- optional `timeout_ms`.
-- Returns:
-- string response body on success
-- nil, errmsg on libcurl failure (non-zero CURLcode)
function M.post(url, body, headers, timeout_ms)
local handle = C.curl_easy_init()
if handle == nil then return nil, "curl_easy_init returned NULL" end
local chunks = {}
local write_cb = ffi.cast(
"size_t(*)(char*, size_t, size_t, void*)",
function(ptr, size, nmemb, _)
local n = tonumber(size) * tonumber(nmemb)
chunks[#chunks+1] = ffi.string(ptr, n)
return n
end)
local slist = nil
for _, h in ipairs(headers or {}) do
slist = C.curl_slist_append(slist, h)
end
setopt_str (handle, OPT.URL, url)
setopt_long(handle, OPT.POST, 1)
setopt_str (handle, OPT.POSTFIELDS, body)
setopt_ptr (handle, OPT.HTTPHEADER, slist)
setopt_ptr (handle, OPT.WRITEFUNCTION, write_cb)
setopt_long(handle, OPT.NOSIGNAL, 1)
setopt_str (handle, OPT.USERAGENT, "aish/0.0 (luajit-ffi)")
if timeout_ms then
setopt_long(handle, OPT.TIMEOUT_MS, timeout_ms)
end
local rc = C.curl_easy_perform(handle)
local result, err
if rc == 0 then
result = table.concat(chunks)
else
err = ffi.string(C.curl_easy_strerror(rc))
end
C.curl_easy_cleanup(handle)
if slist ~= nil then C.curl_slist_free_all(slist) end
write_cb:free()
if rc == 0 then return result end
return nil, err
end
return M