--[[
WinHTTP helper library for OBS lua
2026-04-25
acknowledgements/thanks
* https://obsproject.com/forum/threads/lua-run-script-hidden-no-command-window.175518/post-647153
* ChatGPT
USAGE:
obs = obslua
local WinHTTP = require("winhttp_helper")
local http = WinHTTP.init(
obs,
"192.168.32.107",
"/system?option=setinfo&login_check_flag=1"
)
function on_scene_change()
http:post_async( '{"scene":"cut"}' ) -- no callback
http:post_async( '{"scene":"cut"}', function(res)
print(res.status .. " " .. res.body)
end)
end
]]
local ffi = require("ffi")
local kernel32 = ffi.load("kernel32")
local winhttp = ffi.load("winhttp")
------------------------------------------------------------
-- CONFIG
------------------------------------------------------------
local USER_AGENT = "OBS-Lua"
local DEFAULT_PORT = 80
local TIMEOUT_MS = 100 --1000
local OBS_TIMER_TICK_INTERVAL = 100 --10
------------------------------------------------------------
-- FFI DEFINITIONS
------------------------------------------------------------
ffi.cdef [[
typedef void* HINTERNET;
typedef const wchar_t* LPCWSTR;
typedef uint32_t DWORD;
typedef uint32_t INTERNET_PORT;
typedef uintptr_t DWORD_PTR;
typedef void* LPVOID;
typedef DWORD* LPDWORD;
typedef int BOOL;
HINTERNET WinHttpOpen(LPCWSTR, DWORD, LPCWSTR, LPCWSTR, DWORD);
HINTERNET WinHttpConnect(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD);
HINTERNET WinHttpOpenRequest(HINTERNET, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR*, DWORD);
BOOL WinHttpSendRequest(HINTERNET, LPCWSTR, DWORD, LPVOID, DWORD, DWORD, DWORD_PTR);
BOOL WinHttpReceiveResponse(HINTERNET, LPVOID);
BOOL WinHttpQueryDataAvailable(HINTERNET, LPDWORD);
BOOL WinHttpReadData(HINTERNET, LPVOID, DWORD, LPDWORD);
BOOL WinHttpQueryHeaders(HINTERNET, DWORD, LPCWSTR, LPVOID, LPDWORD, LPDWORD);
BOOL WinHttpCloseHandle(HINTERNET);
BOOL WinHttpSetTimeouts(HINTERNET, int, int, int, int);
int MultiByteToWideChar(uint32_t, DWORD, const char*, int, wchar_t*, int);
DWORD GetLastError(void);
]]
------------------------------------------------------------
-- UTF8 -> UTF16
------------------------------------------------------------
local function utf8_to_wide(str)
-- CP_UTF8 = 65001
local needed = kernel32.MultiByteToWideChar(65001, 0, str, -1, nil, 0)
if needed == 0 then
return nil
end
local buf = ffi.new("wchar_t[?]", needed)
kernel32.MultiByteToWideChar(65001, 0, str, -1, buf, needed)
return buf --should return a null-terminated wide string
end
------------------------------------------------------------
-- MODULE
------------------------------------------------------------
local WinHTTP = {}
WinHTTP.__index = WinHTTP
------------------------------------------------------------
-- INIT (OBS injects timer automatically)
------------------------------------------------------------
function WinHTTP.init(obs, default_host, default_path, default_port)
-- if script was reloaded, clean old instance first
if WinHTTP.instance then
WinHTTP.instance:shutdown()
end
local self = setmetatable({}, WinHTTP)
self.obs = obs
self.default_host = default_host
self.default_port = default_port or DEFAULT_PORT
self.default_path = default_path or "/"
self.queue = {}
self.active = {}
self._timer_cb = function() self:pump() end
obs.timer_add(self._timer_cb, OBS_TIMER_TICK_INTERVAL)
WinHTTP.instance = self
return self
end
------------------------------------------------------------
-- set_config
------------------------------------------------------------
function WinHTTP:set_config(host, path, port)
self.default_host = host or self.default_host
self.default_port = port or self.default_port
self.default_path = path or self.default_path
end
------------------------------------------------------------
-- SHUTDOWN
------------------------------------------------------------
function WinHTTP:shutdown()
-- stop timer
if self._timer_cb then
self.obs.timer_remove(self._timer_cb)
self._timer_cb = nil
end
-- clear queues
self.queue = {}
-- clear singleton reference
if WinHTTP.instance == self then
WinHTTP.instance = nil
end
end
------------------------------------------------------------
-- PUBLIC API (scene-safe entry)
------------------------------------------------------------
function WinHTTP:post_async(body, cb)
table.insert(self.queue, {
host = self.default_host, -- host_override or self.default_host,
port = self.default_port, -- port_override or self.default_port,
path = self.default_path, -- path_override or self.default_path,
method = "POST",
headers = "Content-Type: application/json",
body = body,
callback = cb or nil,
session = nil,
connect = nil,
handle = nil
})
end
------------------------------------------------------------
-- ERROR HANDLER
------------------------------------------------------------
local function fail(self, req, msg)
local err = "'" .. msg .. " (GetLastError: " .. ffi.C.GetLastError() .. ")'"
--print(err) a print here does not appear in OBS logs unless the calling script also logs something
if req.handle then winhttp.WinHttpCloseHandle(req.handle) end
if req.connect then winhttp.WinHttpCloseHandle(req.connect) end
if req.session then winhttp.WinHttpCloseHandle(req.session) end
req.chunks = nil
if req.callback then
req.callback({ status = "error", body = err })
end
end
------------------------------------------------------------
-- PUMP (runs on OBS timer)
------------------------------------------------------------
function WinHTTP:pump()
--------------------------------------------------------
-- process ONE queued request per tick
--------------------------------------------------------
if #self.queue == 0 then return end
local req = table.remove(self.queue, 1)
----------------------------------------------------
-- create session
----------------------------------------------------
req.session = winhttp.WinHttpOpen( utf8_to_wide(USER_AGENT), 1, nil, nil, 0 )
-- WINHTTP_ACCESS_TYPE_NO_PROXY=1
if req.session == nil then -- consider == ffi.NULL instead of == nil
print("ERROR: WinHttpOpen failed")
return
end
winhttp.WinHttpSetTimeouts( req.session, TIMEOUT_MS, TIMEOUT_MS, TIMEOUT_MS, TIMEOUT_MS )
----------------------------------------------------
-- connect
----------------------------------------------------
req.connect = winhttp.WinHttpConnect( req.session, utf8_to_wide(req.host), req.port, 0 )
if req.connect == nil then -- consider == ffi.NULL instead of == nil
fail(self, req, "WinHttpConnect failed")
return
end
----------------------------------------------------
-- open request
----------------------------------------------------
local flags = 0
if req.port == 443 then flags = 0x00800000 end -- WINHTTP_FLAG_SECURE
req.handle = winhttp.WinHttpOpenRequest( req.connect, utf8_to_wide(req.method), utf8_to_wide(req.path), nil, nil, nil, flags )
if req.handle == nil then -- consider == ffi.NULL instead of == nil
fail(self, req, "WinHttpOpenRequest failed")
return
end
----------------------------------------------------
-- send
----------------------------------------------------
local headers_w = utf8_to_wide(req.headers)
local body_len = req.body and #req.body or 0
local body_buf = nil
if body_len > 0 then
body_buf = ffi.new("char[?]", body_len)
ffi.copy(body_buf, req.body, body_len)
end
if winhttp.WinHttpSendRequest( req.handle, headers_w, -1, body_buf, body_len, body_len, 0 ) == 0 then
fail(self, req, "WinHttpSendRequest failed")
return
end
if req.callback then
-- specifying callback implies we want to read the response
----------------------------------------------------
-- receive
----------------------------------------------------
if winhttp.WinHttpReceiveResponse( req.handle, nil ) == 0 then
fail(self, req, "WinHttpReceiveResponse failed")
return
end
----------------------------------------------------
-- query status codes
----------------------------------------------------
local status = ffi.new("uint32_t[1]")
local status_len = ffi.new("uint32_t[1]", 4) -- sizeof(DWORD)
if winhttp.WinHttpQueryHeaders( req.handle, 0x20000013, nil, status, status_len, nil ) == 0 then
-- 0x20000013 = WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER
fail(self, req, "WinHttpQueryHeaders failed")
return
end
local http_status = tonumber(status[0])
if http_status < 200 or http_status >= 300 then
fail(self, req, "HTTP status " .. http_status)
return
end
----------------------------------------------------
-- read data
----------------------------------------------------
local chunks = {}
local size = ffi.new("uint32_t[1]")
local read = ffi.new("uint32_t[1]")
while true do
if winhttp.WinHttpQueryDataAvailable( req.handle, size ) == 0 then
fail(self, req, "WinHttpQueryDataAvailable failed")
return
end
local available = size[0]
if available == 0 then break end
local buf = ffi.new("char[?]", available)
if winhttp.WinHttpReadData( req.handle, buf, available, read ) == 0 then
fail(self, req, "WinHttpReadData failed")
return
end
table.insert( chunks, ffi.string(buf, read[0]) )
end
----------------------------------------------------
-- return the responses via the callback
----------------------------------------------------
req.callback({
status = http_status,
body = table.concat(chunks)
})
end
--------------------------------------------------------
-- close handles
--------------------------------------------------------
winhttp.WinHttpCloseHandle(req.handle)
winhttp.WinHttpCloseHandle(req.connect)
winhttp.WinHttpCloseHandle(req.session)
end
------------------------------------------------------------
-- EXPORT
------------------------------------------------------------
return WinHTTP