Proposal: HTTP client standard library (Http.*) — async poll-based, TLS, JSON #6

Closed
opened 2026-08-29 18:13:01 +02:00 by orkun · 1 comment
Owner

Context

Modern games talk to the web: leaderboards, cloud saves, remote config/feature flags, analytics, news/MOTD, asset downloads, and simple web APIs. Ludic has raw sockets via the networking primitives (net_send/net_poll, see the Net.* proposal in #2) but no HTTP. This proposes an Http.* standard-library client, modeled on Go's net/http (client, methods, headers, timeouts, TLS) and Godot's HTTPRequest (async, TLS with certificate verification).

Explicitly outside the deterministic simulation. HTTP depends on the network and wall-clock time; like Net.* and Time.now, its results must never feed the lockstep/replay sim without deliberate handling. It is for out-of-band data (scores, config, telemetry), not gameplay state.

Async model — never block the frame

A game loop can't stall on a socket. Two complementary forms:

  1. Handle + poll (primary; matches net_poll and the deterministic frame loop, no threads/promises):
let req = Http.get("https://api.example.com/scores?level=3")
# ...each frame...
let res = Http.poll(req)                 # -> option<Response> (see #1)
if res.some {
  if res.value.status == 200 { let body = res.value.text }
}
  1. Callback sugar for convenience:
Http.get("https://.../motd", on_done: fn(res) { Screen.status(res.text) })

Request / response

let req = Http.request(
  method: Http.POST,
  url: "https://api.example.com/score",
  headers: [ Http.header("Authorization", token) ],
  timeout: fx(10),
  body: Json.stringify(payload))          # or Http.form(...), or raw bytes

# Response: { status: int, headers, text: str, bytes: ptr, ok: bool }
  • Methods: GET POST PUT PATCH DELETE HEAD.
  • Bodies: JSON, form-urlencoded, raw bytes; helpers Http.form(...), Http.multipart(...).
  • Response body as str or raw bytes (for downloads); res.json() returns a parsed value.
  • Config: timeout, retry (Go go-resty style WithRetry/WithTimeout), redirect policy, default headers, base-URL client (Http.client(base_url, headers) reused across calls, like Go's http.Client).

JSON companion (Json.)

HTTP needs (de)serialization: Json.parse(str) -> value, Json.stringify(value) -> str. This depends on a dynamic/tagged value type (see the tagged-union enums in #1); until then, parse into property records by shape.

Security

  • TLS on by default with server-certificate verification (like Godot HTTPRequest); an explicit opt-out for dev only.
  • Never put secrets in URLs; support an Authorization header helper.

Platform mapping

  • Native: on top of the transport seam (sockets + a TLS impl).
  • wasm (future): maps to the browser fetch() — same Http.* surface, different backend. (CORS applies on web.)

Phasing

  1. Http.get/post + handle/poll + Response{status, text, ok} + TLS.
  2. Full request builder (methods, headers, timeout, bytes), Json.parse/stringify.
  3. Reusable Http.client(base_url), retries, redirects, downloads to bytes.
  4. Callback sugar; wasm fetch backend.

References

Async, poll-based HTTP that respects the frame loop and stays out of the deterministic sim.

## Context Modern games talk to the web: leaderboards, cloud saves, remote config/feature flags, analytics, news/MOTD, asset downloads, and simple web APIs. Ludic has raw sockets via the networking primitives (`net_send`/`net_poll`, see the `Net.*` proposal in #2) but no HTTP. This proposes an `Http.*` standard-library client, modeled on **Go's `net/http`** (client, methods, headers, timeouts, TLS) and **Godot's `HTTPRequest`** (async, TLS with certificate verification). > **Explicitly outside the deterministic simulation.** HTTP depends on the network and wall-clock time; like `Net.*` and `Time.now`, its results must never feed the lockstep/replay sim without deliberate handling. It is for out-of-band data (scores, config, telemetry), not gameplay state. ## Async model — never block the frame A game loop can't stall on a socket. Two complementary forms: 1. **Handle + poll** (primary; matches `net_poll` and the deterministic frame loop, no threads/promises): ``` let req = Http.get("https://api.example.com/scores?level=3") # ...each frame... let res = Http.poll(req) # -> option<Response> (see #1) if res.some { if res.value.status == 200 { let body = res.value.text } } ``` 2. **Callback** sugar for convenience: ``` Http.get("https://.../motd", on_done: fn(res) { Screen.status(res.text) }) ``` ## Request / response ``` let req = Http.request( method: Http.POST, url: "https://api.example.com/score", headers: [ Http.header("Authorization", token) ], timeout: fx(10), body: Json.stringify(payload)) # or Http.form(...), or raw bytes # Response: { status: int, headers, text: str, bytes: ptr, ok: bool } ``` - Methods: `GET POST PUT PATCH DELETE HEAD`. - Bodies: JSON, form-urlencoded, raw bytes; helpers `Http.form(...)`, `Http.multipart(...)`. - Response body as `str` or raw bytes (for downloads); `res.json()` returns a parsed value. - Config: `timeout`, `retry` (Go go-resty style `WithRetry`/`WithTimeout`), redirect policy, default headers, base-URL client (`Http.client(base_url, headers)` reused across calls, like Go's `http.Client`). ## JSON companion (`Json.`) HTTP needs (de)serialization: `Json.parse(str) -> value`, `Json.stringify(value) -> str`. This depends on a dynamic/tagged value type (see the tagged-union enums in #1); until then, parse into `property` records by shape. ## Security - **TLS on by default** with server-certificate verification (like Godot HTTPRequest); an explicit opt-out for dev only. - Never put secrets in URLs; support an `Authorization` header helper. ## Platform mapping - **Native:** on top of the transport seam (sockets + a TLS impl). - **wasm (future):** maps to the browser `fetch()` — same `Http.*` surface, different backend. (CORS applies on web.) ## Phasing 1. `Http.get`/`post` + handle/`poll` + `Response{status, text, ok}` + TLS. 2. Full request builder (methods, headers, timeout, bytes), `Json.parse`/`stringify`. 3. Reusable `Http.client(base_url)`, retries, redirects, downloads to bytes. 4. Callback sugar; wasm `fetch` backend. ## References - [Go `net/http` client](https://pkg.go.dev/net/http#Client) (Client, methods, headers, timeouts, TLS); go-resty async/retry patterns. - [Godot `HTTPRequest`/`HTTPClient`](https://docs.godotengine.org/en/stable/classes/class_httprequest.html) (async, TLS + certificate verification). - Related: `Net.*` transport in #2; `option`/tagged-union value in #1. _Async, poll-based HTTP that respects the frame loop and stays out of the deterministic sim._
orkun added the
proposal
priority:low
area:net
labels 2026-08-29 19:51:36 +02:00
Author
Owner

Shipped in 3df6640 — a poll-based Http.* client. The JSON companion the proposal asked for already landed as Json.* in #44, so pair them: Json.parse(Http.text(h)).

Async model — handle + poll (the proposal's primary form). Ludic has no closures, so the callback-sugar variant isn't expressible; the poll form is the whole API and matches the deterministic frame loop:

let h = Http.get("https://api.example.com/scores?level=3")
# ...each frame...
let st = Http.poll(h)          # -1 pending, 0 transport error, else HTTP status
if st > 0 {
  if Http.ok(h) { let body = Http.text(h) }
  Http.free(h)
}

Http.open(method, url) + Http.set(h, name, value) + Http.body(h, ...) / Http.body_bytes(h, ptr, len) + Http.send(h) build a request up before dispatch; Http.get / Http.post / Http.request are the one-shots. Response side: Http.status / Http.ok / Http.text / Http.body_len / Http.header(h, name) (case-insensitive) / Http.free.

Transport & TLS. runtime/native/http.ll drives NSURLConnection through the objc runtime's C ABI — the same hand-written-IR, no-ObjC/no-C style as cocoa.ll — on a detached pthread, so the frame never blocks. A fixed slot pool holds each in-flight request; the worker publishes status/body/the retained response behind an atomic done flag (release/acquire), which the poller reads. TLS is the system's, on by default with certificate verification (an https:// URL just works), as required. It's spliced and links Foundation only when a program actually uses Http.*.

Pure parser. Http.parse(bytes, len) + the header lookup are pure Ludic and transport-independent (useful for caches / custom transports / tests), so the suite exercises them offline with no network.

Determinism. HTTP depends on the network and wall clock and is explicitly out-of-band — it never feeds the lockstep/replay sim, exactly as the proposal (and Net.* / Time.now) require.

Scope notes. The transport is macOS-only for now (the toolchain's platform); the parser is portable. Redirects/retry policy and a reusable base-URL client are natural follow-ups. Also added the \r string escape the protocol needs.

Verified end-to-end against real endpoints — HTTPS GET (200 + Content-Type + body) and POST (JSON body + custom header, 200/ok). Full + self-host suites green (81 + 29), including a Darwin-gated examples/library/http.ludic that self-checks the parser.

Shipped in 3df6640 — a poll-based `Http.*` client. The JSON companion the proposal asked for already landed as `Json.*` in #44, so pair them: `Json.parse(Http.text(h))`. **Async model — handle + poll (the proposal's primary form).** Ludic has no closures, so the callback-sugar variant isn't expressible; the poll form is the whole API and matches the deterministic frame loop: ``` let h = Http.get("https://api.example.com/scores?level=3") # ...each frame... let st = Http.poll(h) # -1 pending, 0 transport error, else HTTP status if st > 0 { if Http.ok(h) { let body = Http.text(h) } Http.free(h) } ``` `Http.open(method, url)` + `Http.set(h, name, value)` + `Http.body(h, ...)` / `Http.body_bytes(h, ptr, len)` + `Http.send(h)` build a request up before dispatch; `Http.get` / `Http.post` / `Http.request` are the one-shots. Response side: `Http.status` / `Http.ok` / `Http.text` / `Http.body_len` / `Http.header(h, name)` (case-insensitive) / `Http.free`. **Transport & TLS.** `runtime/native/http.ll` drives NSURLConnection through the objc runtime's C ABI — the same hand-written-IR, no-ObjC/no-C style as cocoa.ll — on a **detached pthread**, so the frame never blocks. A fixed slot pool holds each in-flight request; the worker publishes status/body/the retained response behind an atomic done flag (release/acquire), which the poller reads. **TLS is the system's, on by default with certificate verification** (an `https://` URL just works), as required. It's spliced and links Foundation only when a program actually uses `Http.*`. **Pure parser.** `Http.parse(bytes, len)` + the header lookup are pure Ludic and transport-independent (useful for caches / custom transports / tests), so the suite exercises them offline with no network. **Determinism.** HTTP depends on the network and wall clock and is explicitly out-of-band — it never feeds the lockstep/replay sim, exactly as the proposal (and `Net.*` / `Time.now`) require. **Scope notes.** The transport is macOS-only for now (the toolchain's platform); the parser is portable. Redirects/retry policy and a reusable base-URL client are natural follow-ups. Also added the `\r` string escape the protocol needs. Verified end-to-end against real endpoints — HTTPS GET (`200` + `Content-Type` + body) and POST (JSON body + custom header, `200`/`ok`). Full + self-host suites green (81 + 29), including a Darwin-gated `examples/library/http.ludic` that self-checks the parser.
orkun closed this issue 2026-08-31 17:11:12 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#6
No description provided.