ソースを参照

Initial commit;

bjorn 1 年間 前
コミット
29645562d3
4 ファイル変更993 行追加0 行削除
  1. 9 0
      CMakeLists.txt
  2. 19 0
      LICENSE
  3. 103 0
      README.md
  4. 862 0
      http.c

+ 9 - 0
CMakeLists.txt

@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.0.0)
+project(lovr-http)
+
+add_library(http MODULE http.c)
+set_target_properties(http PROPERTIES PREFIX "")
+
+if(WIN32)
+  target_link_libraries(http wininet)
+endif()

+ 19 - 0
LICENSE

@@ -0,0 +1,19 @@
+Copyright (c) 2023 Bjorn Swenson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 103 - 0
README.md

@@ -0,0 +1,103 @@
+lovr-http
+===
+
+An HTTP(S) plugin for Lua.  lovr-http's design was inspired by
+[lua-https](https://github.com/love2d/lua-https). Although the name is lovr-http, the library is
+self-contained and doesn't rely on any parts of LÖVR, so it should work in any Lua program.  It was
+just designed to be used as a LÖVR plugin.
+
+Example
+---
+
+```lua
+http = require 'http'
+
+status, data = http.request('https://zombo.com')
+
+print('welcome')
+print(status)
+print(data)
+```
+
+API
+---
+
+The module has one function:
+
+```lua
+status, data, headers = http.request(url, [options])
+```
+
+### Arguments
+
+`url` is the URL to request.  It should start with the protocol (`http://` or `https://`).
+
+`options` is optional, and is used for advanced request settings.
+
+`options.method` is the HTTP method to use, also called the verb.  `GET` is used by default if
+there's no data in the request, otherwise it defauls to `POST`.
+
+`options.data` is the data to send to the server, also called the body.  It can be a few different
+types:
+
+- When `data` is nil, no request body will be sent (and `method` will default to `GET`).
+- When `data` is a string, the string will be used directly as the request body.
+- When `data` is a table, then pairs in the table will be URL encoded and concatenated together to
+  form an `application/x-www-urlencoded` body.  For example, if data is `{ n = 10, k = 'v!' }`, then
+  the request body will be something like `k=v%21&n=10`.  Keys can appear in any order.  Table pairs
+  will only be used if the key is a string and the value is a string or number.
+- When `data` is a lightuserdata, the data pointed to by the lightuserdata will be used as the
+  request body.  Additionally, the `datasize` option should be an integer indicating how big the
+  request body is, in bytes.
+
+When `options.data` is set, the `Content-Type` request header will default to
+`application/x-www-urlencoded` unless it's set to something else.
+
+`options.headers` is a table of request headers to send to the server.  Pairs in the table will only
+be used if the key is a string and the value is a string or number.
+
+### Returns
+
+If an error occurs, the function returns `nil, errormessage`.
+
+Otherwise, 3 values are returned:
+
+- `status` is an integer with the HTTP status code (200 is OK, 404 is Not Found, etc.).
+- `data` is a string with the data sent by the server (HTML, JSON, binary, etc.).
+- `headers` is a table of response headers.
+
+Limitations
+---
+
+- `multipart/form-data` request bodies are not supported.
+- Multi-line response headers are not parsed correctly on all platforms.
+- There is no way to specify a request timeout, because not all platforms support it.
+- There is currently no way to limit or restrict HTTP redirects.
+- Adding credentials in the URL is not supported.  Use the `Authorization` request header instead.
+- There are differences in behavior between platforms.  If you encounter any that are causing you
+  problems, please open an issue.
+- I don't even know what a proxy is but it's probably not going to work.
+
+Compiling
+---
+
+The build system is CMake.  The CMake script doesn't have any logic to link against Lua yet, so it
+will only build properly in LÖVR's `plugins` folder, where it will automatically use LÖVR's copy of
+Lua.
+
+Implementation
+---
+
+`lovr-http` uses system-provided HTTP libraries:
+
+- Windows uses wininet.
+- Linux uses curl (must be installed, but most systems have it).
+- Android uses Java's HttpURLConnection via JNI.
+- macOS uses NSURLSession.
+
+The system's certificates are used for HTTPS.
+
+License
+---
+
+MIT, see the [LICENSE](./LICENSE) file for details.

+ 862 - 0
http.c

@@ -0,0 +1,862 @@
+#include <stdlib.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+#include <stdbool.h>
+
+typedef void fn_header(void* userdata, const char* name, size_t nameLength, const char* value, size_t valueLength);
+
+typedef struct {
+  const char* url;
+  const char* method;
+  const char** headers;
+  uint32_t headerCount;
+  const char* data;
+  size_t size;
+} http_request_t;
+
+typedef struct {
+  const char* error;
+  uint32_t status;
+  char* data;
+  size_t size;
+  fn_header* header_cb;
+  void* userdata;
+} http_response_t;
+
+#if defined(_WIN32)
+#define WIN32_LEAN_AND_MEAN
+#include <windows.h>
+#include <wininet.h>
+
+static HINTERNET internet;
+
+static void http_init(void) {
+  internet = InternetOpen("LOVR", INTERNET_OPEN_TYPE_PRECONFIG, NULL, NULL, 0);
+}
+
+static void http_destroy(void) {
+  InternetCloseHandle(internet);
+}
+
+static bool http_request(http_request_t* req, http_response_t* res) {
+  if (req->size > UINT32_MAX) {
+    res->error = "request data too large";
+    return false;
+  }
+
+  if (!internet) {
+    res->error = "unknown error";
+    return false;
+  }
+
+  // parse URL (rejects <username>[:<password>]@ and :<port>)
+  size_t length = strlen(req->url);
+  const char* url = req->url;
+  bool https = false;
+
+  if (length > 8 && !memcmp(url, "https://", 8)) {
+    https = true;
+    length -= 8;
+    url += 8;
+  } else if (length > 7 && !memcmp(url, "http://", 7)) {
+    length -= 7;
+    url += 7;
+  } else {
+    res->error = "invalid url";
+    return false;
+  }
+
+  if (strchr(url, '@') || strchr(url, ':')) {
+    res->error = "invalid url";
+    return false;
+  }
+
+  char host[256];
+  char* path = strchr(url, '/');
+  size_t hostLength = path ? path - url : length;
+  if (sizeof(host) > hostLength) {
+    memcpy(host, url, hostLength);
+    host[hostLength] = '\0';
+  } else {
+    res->error = "invalid url";
+    return false;
+  }
+
+  // connection
+  INTERNET_PORT port = https ? INTERNET_DEFAULT_HTTPS_PORT : INTERNET_DEFAULT_HTTP_PORT;
+  HINTERNET connection = InternetConnectA(internet, host, port, NULL, NULL, INTERNET_SERVICE_HTTP, 0, 0);
+  if (!connection) {
+    res->error = "system error while setting up request";
+    return false;
+  }
+
+  // setup request
+  const char* method = req->method ? req->method : (req->data ? "POST" : "GET");
+  DWORD flags = 0;
+  flags |= INTERNET_FLAG_NO_AUTH;
+  flags |= INTERNET_FLAG_NO_CACHE_WRITE;
+  flags |= INTERNET_FLAG_NO_COOKIES;
+  flags |= INTERNET_FLAG_NO_UI;
+  flags |= https ? INTERNET_FLAG_SECURE : 0;
+  HINTERNET request = HttpOpenRequestA(connection, method, path, NULL, NULL, NULL, flags, 0);
+  if (!request) {
+    InternetCloseHandle(connection);
+    res->error = "system error while setting up request";
+    return false;
+  }
+
+  // request headers
+  if (req->headerCount >= 0) {
+    char* header = NULL;
+    size_t capacity = 0;
+    for (uint32_t i = 0; i < req->headerCount; i++) {
+      const char* name = *req->headers++;
+      const char* value = *req->headers++;
+      const char* format = "%s: %s\r\n";
+      int length = snprintf(NULL, 0, format, name, value);
+      if (length > UINT32_MAX) continue;
+      if (length + 1 > capacity) {
+        capacity = length + 1;
+        header = realloc(header, capacity);
+        if (!header) {
+          InternetCloseHandle(connection);
+          res->error = "out of memory";
+          return false;
+        }
+      }
+      sprintf(header, format, name, value);
+      HttpAddRequestHeadersA(request, header, (DWORD) length, HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE);
+    }
+    free(header);
+  }
+
+  // do the thing
+  bool success = HttpSendRequestA(request, NULL, 0, (void*) req->data, (DWORD) req->size);
+  if (!success) {
+    InternetCloseHandle(connection);
+    res->error = "system error while sending request";
+    return false;
+  }
+
+  // status
+  DWORD status;
+  DWORD bufferSize = sizeof(status);
+  DWORD index = 0;
+  HttpQueryInfoA(request, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &status, &bufferSize, &index);
+  res->status = status;
+  index = 0;
+
+  // response headers
+  char stack[1024];
+  char* buffer = stack;
+  bufferSize = sizeof(stack);
+  success = HttpQueryInfoA(request, HTTP_QUERY_RAW_HEADERS, buffer, &bufferSize, &index);
+  if (!success) {
+    if (GetLastError() == ERROR_INSUFFICIENT_BUFFER) {
+      buffer = malloc(bufferSize);
+
+      if (!buffer) {
+        InternetCloseHandle(request);
+        InternetCloseHandle(connection);
+        res->error = "out of memory";
+        return false;
+      }
+
+      success = HttpQueryInfoA(request, HTTP_QUERY_RAW_HEADERS, buffer, &bufferSize, &index);
+    }
+
+    if (!success) {
+      if (buffer != stack) free(buffer);
+      InternetCloseHandle(request);
+      InternetCloseHandle(connection);
+      res->error = "system error while parsing headers";
+      return false;
+    }
+  }
+
+  char* header = buffer;
+  while (*header) {
+    size_t length = strlen(header);
+    char* colon = strchr(header, ':');
+    if (colon && colon != header && length >= (size_t) (colon - header + 2)) {
+      char* name = header;
+      char* value = colon + 2;
+      size_t nameLength = colon - header;
+      size_t valueLength = length - (colon - header + 2);
+      res->header_cb(res->userdata, name, nameLength, value, valueLength);
+    }
+    header += length + 1;
+  }
+
+  if (buffer != stack) {
+    free(buffer);
+  }
+
+  // body
+  res->data = NULL;
+  res->size = 0;
+
+  for (;;) {
+    DWORD bytes = 0;
+    if (!InternetQueryDataAvailable(request, &bytes, 0, 0)) {
+      free(res->data);
+      InternetCloseHandle(request);
+      InternetCloseHandle(connection);
+      res->error = "system error while reading response";
+      return false;
+    }
+
+    if (bytes == 0) {
+      break;
+    }
+
+    res->data = realloc(res->data, res->size + bytes);
+
+    if (!res->data) {
+      InternetCloseHandle(request);
+      InternetCloseHandle(connection);
+      res->error = "out of memory";
+      return false;
+    }
+
+    if (InternetReadFile(request, res->data + res->size, bytes, &bytes)) {
+      res->size += bytes;
+    } else {
+      free(res->data);
+      InternetCloseHandle(request);
+      InternetCloseHandle(connection);
+      res->error = "system error while reading response";
+      return false;
+    }
+  }
+
+  InternetCloseHandle(request);
+  InternetCloseHandle(connection);
+  return true;
+}
+
+#elif defined(__ANDROID__)
+#include <jni.h>
+
+static JavaVM* jvm;
+
+// LÖVR calls this before loading the plugin
+jint JNI_OnLoad(JavaVM* vm, void* reserved) {
+  jvm = vm;
+  return 0;
+}
+
+static void http_init(void) {
+  //
+}
+
+static void http_destroy(void) {
+  //
+}
+
+static bool handleException(JNIEnv* jni, http_response_t* response, const char* message) {
+  if ((*jni)->ExceptionCheck(jni)) {
+    (*jni)->ExceptionClear(jni);
+    response->error = message;
+    return true;
+  }
+  return false;
+}
+
+static bool http_request(http_request_t* request, http_response_t* response) {
+  if (!jvm) {
+    response->error = "Java VM not available";
+    return false;
+  }
+
+  JNIEnv* jni;
+  if ((*jvm)->GetEnv(jvm, (void**) &jni, JNI_VERSION_1_6) == JNI_EDETACHED) {
+    response->error = "Java VM not attached to this thread ;_;";
+    return false;
+  }
+
+  // URL jurl = new URL(request->url);
+  jclass jURL = (*jni)->FindClass(jni, "java/net/URL");
+  jmethodID jURL_init = (*jni)->GetMethodID(jni, jURL, "<init>", "(Ljava/lang/String;)V");
+  jstring jurlstring = (*jni)->NewStringUTF(jni, request->url);
+  jobject jurl = (*jni)->NewObject(jni, jURL, jURL_init, jurlstring);
+  if (handleException(jni, response, "invalid url")) return false;
+  (*jni)->DeleteLocalRef(jni, jurlstring);
+
+  // HttpURLConnection jconnection = (HttpURLConnection) jurl.openConnection();
+  jmethodID jURL_openConnection = (*jni)->GetMethodID(jni, jURL, "openConnection", "()Ljava/net/URLConnection;");
+  jobject jconnection = (*jni)->CallObjectMethod(jni, jurl, jURL_openConnection);
+  if (handleException(jni, response, "connection failure")) return false;
+  (*jni)->DeleteLocalRef(jni, jurl);
+  (*jni)->DeleteLocalRef(jni, jURL);
+
+  // jconnection.setRequestMethod(method);
+  jclass jHttpURLConnection = (*jni)->FindClass(jni, "java/net/HttpURLConnection");
+  jmethodID jHttpURLConnection_setRequestMethod = (*jni)->GetMethodID(jni, jHttpURLConnection, "setRequestMethod", "(Ljava/lang/String;)V");
+  const char* method = request->method ? request->method : (request->data ? "POST" : "GET");
+  jstring jmethod = (*jni)->NewStringUTF(jni, method);
+  (*jni)->CallVoidMethod(jni, jconnection, jHttpURLConnection_setRequestMethod, jmethod);
+  if (handleException(jni, response, "invalid request method")) return false;
+  (*jni)->DeleteLocalRef(jni, jmethod);
+
+  // jconnection.setRequestProperty(headerName, headerValue);
+  jmethodID jURLConnection_setRequestProperty = (*jni)->GetMethodID(jni, jHttpURLConnection, "setRequestProperty", "(Ljava/lang/String;Ljava/lang/String;)V");
+  for (uint32_t i = 0; i < request->headerCount; i++) {
+    jstring jname = (*jni)->NewStringUTF(jni, request->headers[2 * i + 0]);
+    jstring jvalue = (*jni)->NewStringUTF(jni, request->headers[2 * i + 1]);
+    (*jni)->CallVoidMethod(jni, jconnection, jURLConnection_setRequestProperty, jname, jvalue);
+    (*jni)->DeleteLocalRef(jni, jname);
+    (*jni)->DeleteLocalRef(jni, jvalue);
+  }
+
+  if (request->data) {
+    // jconnection.setDoOutput(true);
+    jmethodID jURLConnection_setDoOutput = (*jni)->GetMethodID(jni, jHttpURLConnection, "setDoOutput", "(Z)V");
+    (*jni)->CallVoidMethod(jni, jconnection, jURLConnection_setDoOutput, true);
+
+    // OutputStream joutput = jconnection.getOutputStream();
+    jmethodID jURLConnection_getOutputStream = (*jni)->GetMethodID(jni, jHttpURLConnection, "getOutputStream", "()Ljava/io/OutputStream;");
+    jobject joutput = (*jni)->CallObjectMethod(jni, jconnection, jURLConnection_getOutputStream);
+    if (handleException(jni, response, "failed to write request data")) return false;
+
+    // joutput.write(request->data);
+    jbyteArray jarray = (*jni)->NewByteArray(jni, request->size);
+    if (handleException(jni, response, "out of memory")) return false;
+
+    jbyte* bytes = (*jni)->GetByteArrayElements(jni, jarray, NULL);
+    memcpy(bytes, request->data, request->size);
+    jclass jOutputStream = (*jni)->FindClass(jni, "java/io/OutputStream");
+    jmethodID jOutputStream_write = (*jni)->GetMethodID(jni, jOutputStream, "write", "([B)V");
+    (*jni)->CallVoidMethod(jni, joutput, jOutputStream_write, jarray);
+    if (handleException(jni, response, "failed to write request data")) return false;
+    (*jni)->ReleaseByteArrayElements(jni, jarray, bytes, 0);
+    (*jni)->DeleteLocalRef(jni, jarray);
+    (*jni)->DeleteLocalRef(jni, joutput);
+    (*jni)->DeleteLocalRef(jni, jOutputStream);
+  }
+
+  // jconnection.connect();
+  jmethodID jURLConnection_connect = (*jni)->GetMethodID(jni, jHttpURLConnection, "connect", "()V");
+  (*jni)->CallVoidMethod(jni, jconnection, jURLConnection_connect);
+  if (handleException(jni, response, "connection failure")) return false;
+
+  // response->status = jconnection.getResponseCode();
+  jmethodID jHttpURLConnection_getResponseCode = (*jni)->GetMethodID(jni, jHttpURLConnection, "getResponseCode", "()I");
+  response->status = (*jni)->CallIntMethod(jni, jconnection, jHttpURLConnection_getResponseCode);
+  if (handleException(jni, response, "connection failure")) return false;
+
+  jmethodID jHttpURLConnection_getHeaderFieldKey = (*jni)->GetMethodID(jni, jHttpURLConnection, "getHeaderFieldKey", "(I)Ljava/lang/String;");
+  jmethodID jHttpURLConnection_getHeaderField = (*jni)->GetMethodID(jni, jHttpURLConnection, "getHeaderField", "(I)Ljava/lang/String;");
+
+  jint headerIndex = 0;
+
+  for (;;) {
+    jstring jname = (*jni)->CallObjectMethod(jni, jconnection, jHttpURLConnection_getHeaderFieldKey, headerIndex);
+    jstring jvalue = (*jni)->CallObjectMethod(jni, jconnection, jHttpURLConnection_getHeaderField, headerIndex);
+
+    if (!jvalue) {
+      break;
+    }
+
+    if (!jname) {
+      headerIndex++;
+      continue;
+    }
+
+    size_t nameLength = (*jni)->GetStringUTFLength(jni, jname);
+    const char* name = (*jni)->GetStringUTFChars(jni, jname, NULL);
+
+    size_t valueLength = (*jni)->GetStringUTFLength(jni, jvalue);
+    const char* value = (*jni)->GetStringUTFChars(jni, jvalue, NULL);
+
+    // TODO name/value use Java's weird "modified UTF" encoding.  It's close to utf8 but not quite.
+    response->header_cb(response->userdata, name, nameLength, value, valueLength);
+
+    (*jni)->ReleaseStringUTFChars(jni, jname, name);
+    (*jni)->ReleaseStringUTFChars(jni, jvalue, value);
+    (*jni)->DeleteLocalRef(jni, jname);
+    (*jni)->DeleteLocalRef(jni, jvalue);
+    headerIndex++;
+  }
+
+  // InputStream jinput = jconnection.getInputStream(); (or getErrorStream)
+  jmethodID jURLConnection_getInputStream = (*jni)->GetMethodID(jni, jHttpURLConnection, "getInputStream", "()Ljava/io/InputStream;");
+  jmethodID jURLConnection_getErrorStream = (*jni)->GetMethodID(jni, jHttpURLConnection, "getErrorStream", "()Ljava/io/InputStream;");
+
+  jobject jinput;
+  if (response->status >= 400) {
+    jinput = (*jni)->CallObjectMethod(jni, jconnection, jURLConnection_getErrorStream);
+  } else {
+    jinput = (*jni)->CallObjectMethod(jni, jconnection, jURLConnection_getInputStream);
+  }
+
+  if (handleException(jni, response, "failed to read response data")) return false;
+
+  jclass jInputStream = (*jni)->FindClass(jni, "java/io/InputStream");
+  jmethodID jInputStream_read = (*jni)->GetMethodID(jni, jInputStream, "read", "([B)I");
+
+  response->data = NULL;
+  response->size = 0;
+
+  jbyteArray jbuffer = (*jni)->NewByteArray(jni, 16384);
+  if (handleException(jni, response, "out of memory")) return false;
+
+  for (;;) {
+    // int bytesRead = jinput.read(buffer);
+    jint bytesRead = (*jni)->CallIntMethod(jni, jinput, jInputStream_read, jbuffer);
+    if (handleException(jni, response, "failed to read response data")) return false;
+
+    if (bytesRead == -1) {
+      break;
+    }
+
+    response->data = realloc(response->data, response->size + bytesRead);
+
+    if (!response->data) {
+      response->error = "out of memory";
+      return false;
+    }
+
+    (*jni)->GetByteArrayRegion(jni, jbuffer, 0, bytesRead, (jbyte*) response->data + response->size);
+    response->size += bytesRead;
+  }
+
+  (*jni)->DeleteLocalRef(jni, jbuffer);
+  (*jni)->DeleteLocalRef(jni, jinput);
+  (*jni)->DeleteLocalRef(jni, jInputStream);
+
+  // jconnection.disconnect();
+  jmethodID jURLConnection_disconnect = (*jni)->GetMethodID(jni, jHttpURLConnection, "disconnect", "()V");
+  (*jni)->CallVoidMethod(jni, jconnection, jURLConnection_disconnect);
+  (*jni)->DeleteLocalRef(jni, jHttpURLConnection);
+  (*jni)->DeleteLocalRef(jni, jconnection);
+
+  return true;
+}
+
+#elif defined(__linux__)
+#include <curl/curl.h>
+#include <dlfcn.h>
+
+typedef CURLcode fn_global_init(long flags);
+typedef void fn_global_cleanup(void);
+typedef CURL* fn_easy_init(void);
+typedef CURLcode fn_easy_setopt(CURL *curl, CURLoption option, ...);
+typedef CURLcode fn_easy_perform(CURL *curl);
+typedef void fn_easy_cleanup(CURL* curl);
+typedef CURLcode fn_easy_getinfo(CURL* curl, CURLINFO info, ...);
+typedef const char* fn_easy_strerror(CURLcode error);
+typedef struct curl_slist *fn_slist_append(struct curl_slist *list, const char *string);
+typedef void fn_slist_free_all(struct curl_slist *list);
+
+#define FN_DECLARE(f) fn_##f* f;
+#define FN_LOAD(f) curl.f = (fn_##f*) dlsym(library, "curl_"#f);
+#define FN_FOREACH(X)\
+  X(global_init)\
+  X(global_cleanup)\
+  X(easy_init)\
+  X(easy_setopt)\
+  X(easy_perform)\
+  X(easy_cleanup)\
+  X(easy_getinfo)\
+  X(easy_strerror)\
+  X(slist_append)\
+  X(slist_free_all)
+
+static struct {
+  FN_FOREACH(FN_DECLARE)
+} curl;
+
+static void* library;
+
+static void http_init(void) {
+  library = dlopen("libcurl.so", RTLD_LAZY);
+
+  if (library) {
+    FN_FOREACH(FN_LOAD)
+    if (curl.global_init(CURL_GLOBAL_DEFAULT)) {
+      dlclose(library);
+      library = NULL;
+    }
+  }
+}
+
+static void http_destroy(void) {
+  if (library) {
+    curl.global_cleanup();
+    dlclose(library);
+  }
+}
+
+static size_t reader(char* buffer, size_t size, size_t count, void* userdata) {
+  char** data = userdata;
+  memcpy(buffer, *data, size * count);
+  *data += size * count;
+  return size * count;
+}
+
+static size_t writer(void* buffer, size_t size, size_t count, void* userdata) {
+  http_response_t* response = userdata;
+  response->data = realloc(response->data, response->size + size * count);
+  if (!response->data) {
+    response->error = "out of memory";
+    return 0;
+  }
+  memcpy(response->data + response->size, buffer, size * count);
+  response->size += size * count;
+  return size * count;
+}
+
+// would rather use curl_easy_nextheader, but it's too new right now
+static size_t onHeader(char* buffer, size_t size, size_t count, void* userdata) {
+  http_response_t* response = userdata;
+  char* colon = memchr(buffer, ':', size * count);
+  if (colon) {
+    char* name = buffer;
+    char* value = colon + 1;
+    size_t nameLength = colon - buffer;
+    size_t valueLength = size * count - (nameLength + 1);
+    while (valueLength > 0 && (*value == ' ' || *value == '\t')) value++, valueLength--;
+    while (valueLength > 0 && (value[valueLength - 1] == '\n' || value[valueLength - 1] == '\r')) valueLength--;
+    response->header_cb(response->userdata, name, nameLength, value, valueLength);
+  }
+  return size * count;
+}
+
+static bool http_request(http_request_t* request, http_response_t* response) {
+  if (!library) return response->error = "curl unavailable", false;
+
+  CURL* handle = curl.easy_init();
+  if (!handle) return response->error = "curl unavailable", false;
+
+  curl.easy_setopt(handle, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
+  curl.easy_setopt(handle, CURLOPT_URL, request->url);
+
+  if (request->method) {
+    curl.easy_setopt(handle, CURLOPT_CUSTOMREQUEST, request->method);
+    if (!strcmp(request->method, "HEAD")) curl.easy_setopt(handle, CURLOPT_NOBODY, 1);
+  }
+
+  if (request->data && (!request->method || (strcmp(request->method, "GET") && strcmp(request->method, "HEAD")))) {
+    const char* data = request->data;
+    curl_off_t size = request->size;
+    curl.easy_setopt(handle, CURLOPT_POST, 1);
+    curl.easy_setopt(handle, CURLOPT_READDATA, &data);
+    curl.easy_setopt(handle, CURLOPT_READFUNCTION, reader);
+    curl.easy_setopt(handle, CURLOPT_POSTFIELDSIZE_LARGE, size);
+  }
+
+  struct curl_slist* headers = NULL;
+  if (request->headerCount > 0) {
+    char* header = NULL;
+    size_t capacity = 0;
+    for (uint32_t i = 0; i < request->headerCount; i++) {
+      const char* name = *request->headers++;
+      const char* value = *request->headers++;
+      const char* format = "%s: %s";
+      int length = snprintf(NULL, 0, format, name, value);
+      if (length + 1 > capacity) {
+        capacity = length + 1;
+        header = realloc(header, capacity);
+        if (!header) {
+          response->error = "out of memory";
+          return false;
+        }
+      }
+      sprintf(header, format, name, value);
+      headers = curl.slist_append(headers, header);
+    }
+    curl.easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
+    free(header);
+  }
+
+  curl.easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1);
+
+  response->size = 0;
+  response->data = NULL;
+  curl.easy_setopt(handle, CURLOPT_WRITEDATA, response);
+  curl.easy_setopt(handle, CURLOPT_WRITEFUNCTION, writer);
+
+  curl.easy_setopt(handle, CURLOPT_HEADERDATA, response);
+  curl.easy_setopt(handle, CURLOPT_HEADERFUNCTION, onHeader);
+
+  CURLcode error = curl.easy_perform(handle);
+
+  if (error != CURLE_OK) {
+    response->error = curl.easy_strerror(error);
+    return false;
+  }
+
+  long status;
+  curl.easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status);
+  response->status = status;
+
+  curl.easy_cleanup(handle);
+  curl.slist_free_all(headers);
+  return true;
+}
+
+#elif defined(__APPLE__)
+#include <objc/objc-runtime.h>
+#include <dispatch/dispatch.h>
+#define cls(T) ((id) objc_getClass(#T))
+#define msg(ret, obj, fn) ((ret(*)(id, SEL)) objc_msgSend)(obj, sel_getUid(fn))
+#define msg1(ret, obj, fn, T1, A1) ((ret(*)(id, SEL, T1)) objc_msgSend)(obj, sel_getUid(fn), A1)
+#define msg2(ret, obj, fn, T1, A1, T2, A2) ((ret(*)(id, SEL, T1, T2)) objc_msgSend)(obj, sel_getUid(fn), A1, A2)
+#define msg3(ret, obj, fn, T1, A1, T2, A2, T3, A3) ((ret(*)(id, SEL, T1, T2, T3)) objc_msgSend)(obj, sel_getUid(fn), A1, A2, A3)
+
+typedef void (^CompletionHandler)(id data, id response, id error);
+
+static void http_init(void) {
+  //
+}
+
+static void http_destroy(void) {
+  //
+}
+
+static bool http_request(http_request_t* request, http_response_t* response) {
+  id NSString = cls(NSString);
+  id urlNS = msg1(id, NSString, "stringWithUTF8String:", const char*, request->url);
+  id url = msg1(id, cls(NSURL), "URLWithString:", id, urlNS);
+  id req = msg1(id, cls(NSMutableURLRequest), "requestWithURL:", id, url);
+
+  // Method
+  const char* method = request->method ? request->method : (request->data ? "POST" : "GET");
+  id methodNS = msg1(id, NSString, "stringWithUTF8String:", const char*, method);
+  msg1(void, req, "setHTTPMethod:", id, methodNS);
+
+  // Body
+  if (request->data && strcmp(method, "GET") && strcmp(method, "HEAD")) {
+    id body = msg3(id, cls(NSData), "dataWithBytesNoCopy:length:freeWhenDone:", void*, (void*) request->data, unsigned long, (unsigned long) request->size, BOOL, NO);
+    msg1(void, req, "setHTTPBody:", id, body);
+  }
+
+  // Headers
+  for (uint32_t i = 0; i < request->headerCount; i++) {
+    id key = msg1(id, NSString, "stringWithUTF8String:", const char*, request->headers[2 * i + 0]);
+    id val = msg1(id, NSString, "stringWithUTF8String:", const char*, request->headers[2 * i + 1]);
+    msg2(void, req, "setValue:forHTTPHeaderField:", id, val, id, key);
+  }
+
+  __block id data = nil;
+  __block id res = nil;
+  __block id error = nil;
+
+  dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
+
+  CompletionHandler onComplete = ^(id d, id r, id e) {
+    data = d;
+    res = r;
+    error = e;
+    dispatch_semaphore_signal(semaphore);
+  };
+
+  // Task
+  id session = msg(id, cls(NSURLSession), "sharedSession");
+  id task = msg2(id, session, "dataTaskWithRequest:completionHandler:", id, req, CompletionHandler, onComplete);
+
+  msg(void, task, "resume");
+
+  dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
+
+  if (data) {
+    response->size = msg(unsigned long, data, "length");
+    response->data = malloc(response->size);
+    if (!response->data) {
+      response->error = "out of memory";
+      return false;
+    }
+    msg2(void, data, "getBytes:length:", void*, response->data, unsigned long, response->size);
+  }
+
+  if (res) {
+    response->status = msg(long, res, "statusCode");
+
+    id headers = msg(id, res, "allHeaderFields");
+    id enumerator = msg(id, headers, "keyEnumerator");
+
+    for (;;) {
+      id keyNS = msg(id, enumerator, "nextObject");
+
+      if (!keyNS) break;
+
+      id valNS = msg1(id, headers, "valueForKey:", id, keyNS);
+
+      const char* key = msg(const char*, keyNS, "UTF8String");
+      const char* val = msg(const char*, valNS, "UTF8String");
+      unsigned long keyLength = msg(unsigned long, keyNS, "length");
+      unsigned long valLength = msg(unsigned long, valNS, "length");
+
+      response->header_cb(response->userdata, key, keyLength, val, valLength);
+    }
+  }
+
+  response->error = "unknown error"; // TODO
+  return !error;
+}
+
+#else
+#error "Unsupported HTTP platform"
+#endif
+
+// Lua API
+
+#include <lua.h>
+#include <lauxlib.h>
+
+static bool isunreserved(char c) {
+  switch (c) {
+    case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': case 'g': case 'h': case 'i':
+    case 'j': case 'k': case 'l': case 'm': case 'n': case 'o': case 'p': case 'q': case 'r':
+    case 's': case 't': case 'u': case 'v': case 'w': case 'x': case 'y': case 'z':
+    case 'A': case 'B': case 'C': case 'D': case 'E': case 'F': case 'G': case 'H': case 'I':
+    case 'J': case 'K': case 'L': case 'M': case 'N': case 'O': case 'P': case 'Q': case 'R':
+    case 'S': case 'T': case 'U': case 'V': case 'W': case 'X': case 'Y': case 'Z':
+    case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': case '0':
+    case '-': case '_': case '.': case '~':
+      return true;
+    default:
+      return false;
+  }
+}
+
+static size_t urlencode(char* dst, const char* str, size_t len) {
+  size_t res = 0;
+  for (size_t i = 0; i < len; i++, str++) {
+    if (isunreserved(*str)) {
+      dst[res++] = *str;
+    } else {
+      dst[res++] = '%';
+      dst[res++] = '0' + *str / 16;
+      dst[res++] = '0' + *str % 16;
+    }
+  }
+  return res;
+}
+
+static void addheader(void* userdata, const char* name, size_t nameLength, const char* value, size_t valueLength) {
+  lua_State* L = userdata;
+  lua_pushlstring(L, name, nameLength);
+  lua_pushlstring(L, value, valueLength);
+  lua_settable(L, 3);
+}
+
+static int l_http_request(lua_State* L) {
+  http_request_t request = { 0 };
+
+  request.url = luaL_checkstring(L, 1);
+
+  char* data = NULL;
+  size_t size = 0;
+  size_t capacity = 0;
+
+  if (lua_istable(L, 2)) {
+    lua_getfield(L, 2, "data");
+    switch (lua_type(L, -1)) {
+      case LUA_TNIL:
+        break;
+      case LUA_TSTRING:
+        request.data = lua_tolstring(L, -1, &request.size);
+        break;
+      case LUA_TTABLE:
+        lua_pushnil(L);
+        while (lua_next(L, -2) != 0) {
+          if (lua_type(L, -2) == LUA_TSTRING && lua_isstring(L, -1)) {
+            size_t keyLength, valLength;
+            const char* key = lua_tolstring(L, -2, &keyLength);
+            const char* val = lua_tolstring(L, -1, &valLength);
+            size_t maxLength = 3 * keyLength + 1 + 3 * valLength + 1;
+            if (size + maxLength > capacity) {
+              capacity = size + maxLength;
+              data = realloc(data, capacity);
+              if (!data) return luaL_error(L, "Out of memory");
+            }
+            size += urlencode(data + size, key, keyLength);
+            data[size++] = '=';
+            size += urlencode(data + size, val, valLength);
+            data[size++] = '&';
+          }
+          lua_pop(L, 1);
+        }
+        request.data = data;
+        request.size = size - 1;
+        break;
+      case LUA_TLIGHTUSERDATA:
+        lua_getfield(L, 2, "datasize");
+        if (lua_type(L, -1) != LUA_TNUMBER) return luaL_error(L, "Expected numeric 'datasize' key");
+        request.data = lua_touserdata(L, -2);
+        request.size = lua_tointeger(L, -1);
+        lua_pop(L, 1);
+        break;
+      default:
+        return luaL_error(L, "Expected string, table, or lightuserdata for request data");
+    }
+    lua_pop(L, 1);
+
+    lua_getfield(L, 2, "method");
+    if (!lua_isnil(L, -1)) request.method = lua_tostring(L, -1);
+    lua_pop(L, 1);
+
+    lua_getfield(L, 2, "headers");
+    if (lua_istable(L, -1)) {
+      lua_pushnil(L);
+      while (lua_next(L, -2) != 0) {
+        if (lua_type(L, -2) == LUA_TSTRING && lua_isstring(L, -1)) {
+          request.headers = realloc(request.headers, (request.headerCount + 1) * 2 * sizeof(char*));
+          if (!request.headers) return luaL_error(L, "Out of memory");
+          request.headers[request.headerCount * 2 + 0] = lua_tostring(L, -2);
+          request.headers[request.headerCount * 2 + 1] = lua_tostring(L, -1);
+          request.headerCount++;
+        }
+        lua_pop(L, 1);
+      }
+    }
+    lua_pop(L, 1);
+  }
+
+  http_response_t response = {
+    .header_cb = addheader,
+    .userdata = L
+  };
+
+  lua_settop(L, 2);
+  lua_newtable(L);
+  bool success = http_request(&request, &response);
+  free(request.headers);
+  free(data);
+
+  if (!success) {
+    lua_pushnil(L);
+    lua_pushstring(L, response.error);
+    return 2;
+  }
+
+  lua_pushinteger(L, response.status);
+  lua_pushlstring(L, response.data, response.size);
+  lua_pushvalue(L, -3);
+  free(response.data);
+  return 3;
+}
+
+int l_http_destroy(lua_State* L) {
+  http_destroy();
+  return 0;
+}
+
+int luaopen_http(lua_State* L) {
+  http_init();
+
+  lua_newtable(L);
+  lua_pushcfunction(L, l_http_request);
+  lua_setfield(L, -2, "request");
+
+  lua_newuserdata(L, sizeof(void*));
+  lua_createtable(L, 0, 1);
+  lua_pushcfunction(L, l_http_destroy);
+  lua_setfield(L, -2, "__gc");
+  lua_setmetatable(L, -2);
+  lua_setfield(L, -2, "");
+  return 1;
+}