Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ destruction invalidate pending callbacks without invoking consumer code.
Browser requests use a per-client namespace and one `AbortController` per
request; native requests disconnect stale completion signals before reuse.

## Zero-body POST

`HttpClientModule.post_zero_body(endpoint, callback, headers)` sends a POST with exactly zero body bytes. `post_json({})` still serializes and sends `{}`. Put authorization in `headers`; it is not a body field. Headers, timeout, and error callbacks are the same as `post_json`.

Native POST sets `Content-Length: 0` only on the Godot `HTTPRequest` path. Web POST omits the fetch body and does not set `Content-Length`, which browsers forbid.

Web fetch uses `redirect: 'manual'`. A conforming browser exposes an opaque redirect, so the bridge does not follow it and does not send a second request. A visible cross-origin `Location` is also rejected. Native GET still follows redirects through Godot `HTTPRequest` and can resend `Authorization` to the redirect target, including another host. This release does not change that pre-existing native GET behavior.

Added in addon version 0.0.4.

`WebSocketClientModule` uses 1 MiB inbound/outbound buffers, at most 64 queued
packets, and rejects outbound UTF-8 text larger than 1 MiB.

Expand All @@ -98,7 +108,7 @@ packets, and rejects outbound UTF-8 text larger than 1 MiB.

## Versioning And Releases

The version in `addon/plugin.cfg` is the addon package version. Releases are created from `main` with the manual release workflow and plain semver tags like `v0.0.1`; the workflow verifies `plugin.cfg`, builds `@aviorstudio_gd-network.zip`, and publishes `@aviorstudio/gd-network` to GDAM.
The version in `addon/plugin.cfg` is the addon package version. `0.0.4` adds `post_zero_body`. Releases are created from `main` with the manual release workflow and plain semver tags like `v0.0.4`; the workflow verifies `plugin.cfg`, builds `@aviorstudio_gd-network.zip`, and publishes `@aviorstudio/gd-network` to GDAM.

## Testing

Expand Down
2 changes: 1 addition & 1 deletion addon/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
name="GD Network"
description="Network primitives (HTTP pool, retry/backoff, rate limiting, windowing, error catalog)."
author="Avior Studio"
version="0.0.3"
version="0.0.4"
script="plugin.gd"
34 changes: 25 additions & 9 deletions addon/src/http_client_module.gd
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,14 @@ func get_json(endpoint: String, callback: Callable, headers: PackedStringArray =
return _execute_request(HTTPClient.METHOD_GET, endpoint, callback, headers)

func post_json(endpoint: String, body: Dictionary, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String:
return _execute_request(HTTPClient.METHOD_POST, endpoint, callback, headers, body)
return _execute_request(HTTPClient.METHOD_POST, endpoint, callback, headers, JSON.stringify(body))

## POST with exactly zero body bytes. post_json({}) still serializes "{}".
func post_zero_body(endpoint: String, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String:
return _execute_request(HTTPClient.METHOD_POST, endpoint, callback, headers, "")

func put_json(endpoint: String, body: Dictionary, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String:
return _execute_request(HTTPClient.METHOD_PUT, endpoint, callback, headers, body)
return _execute_request(HTTPClient.METHOD_PUT, endpoint, callback, headers, JSON.stringify(body))

func delete_json(endpoint: String, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String:
return _execute_request(HTTPClient.METHOD_DELETE, endpoint, callback, headers)
Expand Down Expand Up @@ -102,7 +106,7 @@ func cancel_request(request_id: String) -> bool:
_send_error(callback, request_id, 0, ERROR_CANCELLED)
return true

func _execute_request(method: HTTPClient.Method, endpoint: String, callback: Callable, extra_headers: PackedStringArray, body: Dictionary = {}) -> String:
func _execute_request(method: HTTPClient.Method, endpoint: String, callback: Callable, extra_headers: PackedStringArray, request_body: String = "") -> String:
_request_counter += 1
_generation_counter += 1
var request_id := str(_request_counter)
Expand All @@ -122,19 +126,19 @@ func _execute_request(method: HTTPClient.Method, endpoint: String, callback: Cal
if method == HTTPClient.METHOD_POST or method == HTTPClient.METHOD_PUT:
headers.insert(0, HEADER_CONTENT_TYPE_JSON)
headers.append_array(extra_headers)
var json_body := ""
var outbound_body := ""
if method == HTTPClient.METHOD_POST or method == HTTPClient.METHOD_PUT:
json_body = JSON.stringify(body)
if json_body.to_utf8_buffer().size() > _config.max_request_body_bytes:
outbound_body = request_body
if outbound_body.to_utf8_buffer().size() > _config.max_request_body_bytes:
_send_error(callback, request_id, 0, ERROR_REQUEST_TOO_LARGE)
return request_id

if OS.has_feature("web"):
_pending_requests[request_id] = RequestEntry.new(callback, _generation_counter)
_schedule_web_timeout(request_id, _generation_counter)
WebFetchBridgeModule.begin_request(_client_id, request_id, full_url, _method_to_string(method), headers, json_body, _config.max_response_body_bytes, _config.max_redirects)
WebFetchBridgeModule.begin_request(_client_id, request_id, full_url, _method_to_string(method), headers, outbound_body, _config.max_response_body_bytes, _config.max_redirects)
else:
_begin_native_request(request_id, full_url, method, headers, json_body, callback)
_begin_native_request(request_id, full_url, method, headers, outbound_body, callback)
return request_id

func _begin_native_request(request_id: String, url: String, method: HTTPClient.Method, headers: PackedStringArray, body: String, callback: Callable) -> void:
Expand All @@ -149,13 +153,25 @@ func _begin_native_request(request_id: String, url: String, method: HTTPClient.M
entry.completion_callable = _on_native_request_completed.bind(request_id, entry.generation)
entry.node.request_completed.connect(entry.completion_callable, CONNECT_ONE_SHOT)
_native_requests[request_id] = entry
var error := entry.node.request(url, headers, method, body)
var error := entry.node.request(url, _native_request_headers(method, headers, body), method, body)
if error != OK:
_native_requests.erase(request_id)
_disconnect_entry(entry)
HttpPoolModule.release_request(entry)
_send_error(callback, request_id, 0, ERROR_REQUEST_FAILED)

## Godot omits Content-Length when the body is empty. Set it only on this native
## path: browsers forbid Content-Length on fetch, so it must not be shared.
func _native_request_headers(method: HTTPClient.Method, headers: PackedStringArray, body: String) -> PackedStringArray:
if method != HTTPClient.METHOD_POST or not body.is_empty():
return headers
for header_line in headers:
if header_line.to_lower().begins_with("content-length:"):
return headers
var native_headers := headers.duplicate()
native_headers.append("Content-Length: 0")
return native_headers

func _on_native_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray, request_id: String, generation: int) -> void:
var entry: HttpPoolModule.PoolEntry = _native_requests.get(request_id, null)
if entry == null or entry.generation != generation or entry.request_id != request_id:
Expand Down
2 changes: 1 addition & 1 deletion addon/src/web_fetch_bridge_module.gd
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,6 @@ static func build_request_script(
+ "if(!resp.body||!resp.body.getReader){const b=await resp.arrayBuffer();if(b.byteLength>maxBytes)throw {key:'response_too_large'};return new TextDecoder().decode(b);}"
+ "const reader=resp.body.getReader(),chunks=[];let total=0;for(;;){const part=await reader.read();if(part.done)break;total+=part.value.byteLength;if(total>maxBytes){controller.abort();throw {key:'response_too_large'};}chunks.push(part.value);}"
+ "const all=new Uint8Array(total);let offset=0;for(const chunk of chunks){all.set(chunk,offset);offset+=chunk.byteLength;}return new TextDecoder().decode(all);};"
+ "const run=async(target,left)=>{const resp=await fetch(target,opts);if(resp.status>=300&&resp.status<400){if(left<=0)throw {key:'too_many_redirects'};const location=resp.headers.get('location');if(!location)throw {key:'redirect_error'};return run(new URL(location,target).href,left-1);}const text=await read(resp);return {ok:resp.ok,status:resp.status,text:text};};"
+ "const run=async(target,left)=>{const resp=await fetch(target,opts);if(resp.type==='opaqueredirect')throw {key:'redirect_error'};if(resp.status>=300&&resp.status<400){if(left<=0)throw {key:'too_many_redirects'};const location=resp.headers.get('location');if(!location)throw {key:'redirect_error'};const next=new URL(location,target);if(next.origin!==new URL(target).origin)throw {key:'redirect_error'};return run(next.href,left-1);}const text=await read(resp);return {ok:resp.ok,status:resp.status,text:text};};"
+ "run(%s,maxRedirects).then(finish).catch(err=>finish({ok:false,status:0,error_key:(err&&err.key)||((err&&err.name)==='AbortError'?'cancelled':'network_error'),error:String(err)}));})();"
) % [GLOBAL_REGISTRY_NAME, JSON.stringify(client_id), JSON.stringify(request_id), JSON.stringify(method), JSON.stringify(header_dict), body_line, max_response_bytes, max_redirects, JSON.stringify(url)]
206 changes: 200 additions & 6 deletions tests/http_client_module_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ extends SceneTree

var _last_payload: Dictionary[String, Variant] = {}
var _callback_count: int = 0
var _transport_active := false
var _transport_server: TCPServer = null
var _transport_peers: Array[Dictionary] = []
var _captured_requests: Dictionary[String, Dictionary] = {}
var _transport_deadline_msec := 0
var _transport_module: Variant = null
var _transport_owner: Node = null
var _transport_port := 0

const _BEARER_HEADER := "Authorization: Bearer secret-token-not-in-body"
const _BEARER_TOKEN := "secret-token-not-in-body"
const _CONFIGURED_TIMEOUT_S := 8.0

func _load_http_client_module() -> Variant:
return load("res://addon/src/http_client_module.gd")
Expand All @@ -14,15 +26,19 @@ func _initialize() -> void:
_test_bounds_validation_and_capacity(failures)
_test_cancel_generation_and_cleanup(failures)
_test_two_client_instances(failures)
_test_zero_body_without_setup(failures)

if failures.is_empty():
print("PASS gd-network http_client_module_test")
quit(0)
if not failures.is_empty():
_finish(failures)
return
_start_zero_body_transport(failures)
if not failures.is_empty():
_finish(failures)

for failure in failures:
push_error(failure)
quit(1)
func _process(_delta: float) -> bool:
if _transport_active:
_poll_zero_body_transport()
return false

func _test_missing_setup_returns_error(failures: Array[String]) -> void:
var http_client_module: Variant = _load_http_client_module()
Expand Down Expand Up @@ -173,6 +189,184 @@ func _test_two_client_instances(failures: Array[String]) -> void:
func _capture_payload(payload: Dictionary[String, Variant]) -> void:
_last_payload = payload

func _test_zero_body_without_setup(failures: Array[String]) -> void:
var http_client_module: Variant = _load_http_client_module()
if http_client_module == null:
failures.append("Failed to load res://addon/src/http_client_module.gd")
return
var fresh_config = http_client_module.HttpClientConfig.new()
if fresh_config.default_timeout_s != 10.0:
failures.append("Expected default per-request timeout to remain 10 seconds")
_last_payload = {}
var module = http_client_module.new()
module.post_zero_body("https://example.com/sign-out", Callable(self, "_capture_payload"), PackedStringArray([_BEARER_HEADER]))
if str(_last_payload.get("error_key", "")) != "request_failed":
failures.append("Expected post_zero_body without setup to fail like other requests")
if str(_last_payload).contains(_BEARER_TOKEN):
failures.append("Expected bearer token to stay out of the zero-body error payload")

func _start_zero_body_transport(failures: Array[String]) -> void:
_transport_port = _listen_transport()
if _transport_port == 0:
failures.append("Expected a local TCP listener for the native zero-body POST")
return
var http_client_module: Variant = _load_http_client_module()
_transport_owner = Node.new()
root.add_child(_transport_owner)
var config = http_client_module.HttpClientConfig.new()
config.default_timeout_s = _CONFIGURED_TIMEOUT_S
_transport_module = http_client_module.new()
_transport_module.setup(_transport_owner, config)
call_deferred("_send_zero_body_transport")

func _send_zero_body_transport() -> void:
var failures: Array[String] = []
var headers := PackedStringArray([_BEARER_HEADER])
var zero_id: String = _transport_module.post_zero_body("http://127.0.0.1:%d/zero" % _transport_port, Callable(), headers)
var json_id: String = _transport_module.post_json("http://127.0.0.1:%d/json" % _transport_port, {}, Callable(), headers)
_assert_native_timeout(failures, zero_id, "post_zero_body")
_assert_native_timeout(failures, json_id, "post_json")
if not failures.is_empty():
_finish(failures)
return
_transport_deadline_msec = Time.get_ticks_msec() + 4000
_transport_active = true

func _assert_native_timeout(failures: Array[String], request_id: String, label: String) -> void:
var entry: Variant = _transport_module._native_requests.get(request_id, null)
if entry == null:
failures.append("Expected %s to enter the native request path" % label)
return
if entry.node.timeout != _CONFIGURED_TIMEOUT_S:
failures.append("Expected %s to keep the configured 8-second timeout, got %s" % [label, str(entry.node.timeout)])

func _listen_transport() -> int:
for port in range(18765, 18785):
var server := TCPServer.new()
if server.listen(port, "127.0.0.1") == OK:
_transport_server = server
return port
server.stop()
return 0

func _poll_zero_body_transport() -> void:
while _transport_server != null and _transport_server.is_connection_available():
var peer: StreamPeerTCP = _transport_server.take_connection()
_transport_peers.append({"peer": peer, "buffer": PackedByteArray(), "done": false})
var index := 0
while index < _transport_peers.size():
var state: Dictionary = _transport_peers[index]
if not bool(state.get("done", false)):
_read_transport_peer(state)
_transport_peers[index] = state
var parsed := _complete_http_request(state.get("buffer", PackedByteArray()))
if not parsed.is_empty():
var path := str(parsed.get("path", ""))
_captured_requests[path] = parsed
var peer: StreamPeerTCP = state.get("peer")
peer.put_data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}".to_utf8_buffer())
state["done"] = true
_transport_peers[index] = state
index += 1
if _captured_requests.has("/zero") and _captured_requests.has("/json"):
var failures: Array[String] = []
_assert_captured_requests(failures)
_finish(failures)
return
if Time.get_ticks_msec() > _transport_deadline_msec:
var failures: Array[String] = []
failures.append("Timed out waiting for native POST bodies; captured %s" % str(_captured_requests.keys()))
_finish(failures)

func _read_transport_peer(state: Dictionary) -> void:
var peer: StreamPeerTCP = state.get("peer")
var available := peer.get_available_bytes()
if available <= 0:
return
var chunk: Array = peer.get_data(available)
if int(chunk[0]) != OK:
return
var buffer: PackedByteArray = state.get("buffer", PackedByteArray())
buffer.append_array(chunk[1])
state["buffer"] = buffer

func _complete_http_request(buffer: PackedByteArray) -> Dictionary:
var raw := buffer.get_string_from_utf8()
var marker := "\r\n\r\n"
var header_end := raw.find(marker)
if header_end < 0:
return {}
var header_text := raw.substr(0, header_end)
var body_text := raw.substr(header_end + marker.length())
var content_length := -1
for line in header_text.split("\r\n"):
if line.to_lower().begins_with("content-length:"):
content_length = int(line.substr(line.find(":") + 1).strip_edges())
if content_length >= 0 and body_text.to_utf8_buffer().size() < content_length:
return {}
if content_length >= 0:
body_text = body_text.substr(0, content_length)
var request_line := header_text.get_slice("\r\n", 0)
return {
"path": request_line.get_slice(" ", 1),
"headers": header_text,
"body": body_text,
"content_length": content_length,
"raw": raw,
}

func _assert_captured_requests(failures: Array[String]) -> void:
var zero: Dictionary = _captured_requests.get("/zero", {})
var json_request: Dictionary = _captured_requests.get("/json", {})
var zero_body := str(zero.get("body", ""))
var json_body := str(json_request.get("body", ""))
if zero_body.to_utf8_buffer().size() != 0:
failures.append("Expected post_zero_body to send zero bytes, got %d (%s)" % [zero_body.to_utf8_buffer().size(), zero_body])
var zero_length_count := 0
for line in str(zero.get("headers", "")).split("\r\n"):
if line.to_lower().begins_with("content-length:"):
zero_length_count += 1
if zero_length_count != 1 or int(zero.get("content_length", -1)) != 0 or not str(zero.get("headers", "")).contains("Content-Length: 0"):
failures.append("Expected exactly one native Content-Length: 0, got count %d value %s" % [zero_length_count, str(zero.get("content_length", -1))])
if str(zero.get("raw", "")).contains("{}"):
failures.append("Expected native zero-body POST to exclude '{}'")
if json_body != "{}":
failures.append("Expected post_json({}) to remain '{}', got %s" % json_body)
if json_body.to_utf8_buffer().size() != 2:
failures.append("Expected post_json({}) to send 2 bytes")
if int(json_request.get("content_length", -1)) != 2:
failures.append("Expected post_json({}) Content-Length to remain 2, got %s" % str(json_request.get("content_length", -1)))
for path in ["/zero", "/json"]:
var captured: Dictionary = _captured_requests.get(path, {})
var headers := str(captured.get("headers", ""))
var body := str(captured.get("body", ""))
if not headers.begins_with("POST "):
failures.append("Expected native POST for %s" % path)
if not headers.contains("Content-Type: application/json"):
failures.append("Expected shared JSON content type on %s" % path)
if not headers.contains("Accept: application/json"):
failures.append("Expected shared Accept header on %s" % path)
if not headers.contains(_BEARER_HEADER):
failures.append("Expected Authorization bearer header on %s" % path)
if body.contains(_BEARER_TOKEN):
failures.append("Expected bearer token to stay out of %s body" % path)

func _finish(failures: Array[String]) -> void:
_transport_active = false
if _transport_module != null:
_transport_module.cleanup()
if _transport_server != null:
_transport_server.stop()
if _transport_owner != null:
_transport_owner.queue_free()
if failures.is_empty():
print("PASS gd-network http_client_module_test")
quit(0)
return
for failure in failures:
push_error(failure)
quit(1)

func _count_payload(payload: Dictionary[String, Variant]) -> void:
_callback_count += 1
_last_payload = payload
Loading
Loading