Commitfbe1eb4aRecorded6 Sep 2026Repositorysigil-http
Preserve binary request bodies in http-fetch-bytes; release 0.18.6
Changed
.woodpecker.yml | 6 +++---
CHANGELOG.md | 12 ++++++++++++
README.md | 17 +++++++++++++++++
package.sgl | 2 +-
src/sigil/http/client.sgl | 21 ++++++++++++++++++---
test/integration/run-upload-tests.mjs | 26 ++++++++++++++++++++++++++
test/test-upload-bytes.sgl | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 127 insertions(+), 7 deletions(-)Diff
.woodpecker.ymlmodified
@@ -21,9 +21,9 @@ steps:
21
test: 22
image: *alpine_image 23
commands:−24
- apk add --no-cache curl ca-certificates git−25
- curl -fLo ./sigil https://codeberg.org/sigil/sigil/releases/download/v0.14.3/sigil-linux-amd64-static+24
- apk add --no-cache curl ca-certificates git nodejs+25
- curl -fLo ./sigil https://codeberg.org/sigil/sigil/releases/download/v0.21.0/sigil-linux-amd64-static 26
- chmod +x ./sigil 27
- ./sigil --version 28
- ./sigil deps install−29
- ./sigil test+29
- PATH="$PWD:$PATH" node test/integration/run-upload-tests.mjsCHANGELOG.mdmodified
@@ -5,6 +5,18 @@ All notable changes to **sigil-http** are documented in this file.
5
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7
+8
## [0.18.6] - 2026-09-06+9
+10
### Fixed+11
+12
- Accept bytevector request bodies in `http-fetch-bytes`, preserving binary+13
uploads without UTF-8 conversion. Compute Content-Length from the bytevector+14
and replace conflicting length/transfer-encoding headers case-insensitively.+15
Existing string request bodies keep their UTF-8 behavior.+16
- Exercise binary assembly and actual empty/2 MB upload round trips alongside+17
the HTTP regression suite. CI now uses Sigil 0.21.0, matching the test runner's+18
existing minimum dependency, and runs the loopback upload checks with Node.+19
20
## [0.18.5] - 2026-08-27 21
22
### AddedREADME.mdmodified
@@ -32,6 +32,23 @@ under `packages/sigil-http/` is preserved here as `master`.
32
See `docs/http.md` for the full API reference, including streaming bodies, 33
Server-Sent Events, and low-level request/response manipulation. 34
+35
## Binary uploads+36
+37
`(sigil http client)` exports `http-fetch-bytes`. Its `body:` argument+38
accepts a string or bytevector; use bytevectors for images and other binary data.+39
The response has `status:`, an ordered `headers:` alist, and bytevector `body:`.+40
Redirects are returned to the caller and transport failures return `#f`.+41
+42
```scheme+43
(import (sigil http client))+44
(http-fetch-bytes 'PUT "https://example.com/object"+45
headers: #{ content-type: "application/octet-stream" }+46
body: image-bytes timeout: 30)+47
```+48
+49
Run `node test/integration/run-upload-tests.mjs` (Node 18+) for the full Sigil+50
suite plus an ephemeral loopback server exercising empty and 2 MB binary bodies.+51
52
## Dependencies 53
54
- `sigil-stdlib`, `sigil-socket`, `sigil-tls` — pinned via `from-git`package.sglmodified
@@ -5,7 +5,7 @@
5
6
(package 7
name: "sigil-http"−8
version: "0.18.5"+8
version: "0.18.6" 9
sigil: "0.20" 10
description: "HTTP client and server library for Sigil" 11
url: "https://codeberg.org/sigil/sigil-http"src/sigil/http/client.sglmodified
@@ -77,6 +77,7 @@
77
chunked-body-complete? 78
framing-complete? 79
;; http-fetch-bytes internals — exported for testing+80
build-request-bytes 81
fetch-parse-headers 82
fetch-content-length 83
fetch-assemble@@ -610,6 +611,20 @@
611
;; Body 612
(or body "")))) 613
+614
;; Byte-faithful request assembly for http-fetch-bytes. Existing string+615
;; request behavior stays unchanged; binary bodies never round-trip UTF-8.+616
(define (build-request-bytes method parsed-url headers body)+617
(if (bytevector? body)+618
(let* ((length-value (number->string (bytevector-length body)))+619
(entries (if (dict? headers)+620
(map (lambda (h) (cons (keyword->string (car h)) (cdr h))) (dict-entries headers))+621
headers))+622
(with-length (cons (cons "content-length" length-value)+623
(filter (lambda (h)+624
(not (member (string-downcase (car h)) '("content-length" "transfer-encoding")))) entries))))+625
(bytevector-append (string->utf8 (build-request-string method parsed-url with-length #f)) body))+626
(string->utf8 (build-request-string method parsed-url headers body))))+627
628
;;; Build header lines from headers (dict or alist) 629
(define (build-header-lines headers) 630
(cond@@ -781,7 +796,7 @@
796
(define (write-all-data conn data deadline) 797
(if (not deadline) 798
(and (conn-write conn data) #t)−784
(let* ((bv (string->utf8 data))+799
(let* ((bv (if (bytevector? data) data (string->utf8 data))) 800
(len (bytevector-length bv))) 801
(let loop ((sent 0)) 802
(cond@@ -1408,7 +1423,7 @@
1423
(define fetch-max-idle-polls 6000) 1424
1425
;;; Fetch `url` (a `method` symbol, optional request `headers` dict and−1411
;;; string `body`) and return the response as raw bytes:+1426
;;; string or bytevector `body`) and return the response as raw bytes: 1427
;;; 1428
;;; #{ status: <integer> 1429
;;; headers: <ordered alist of (lowercased-name . value)>@@ -1471,7 +1486,7 @@
1486
(conn-set-non-blocking! conn) 1487
(let ((deadline (timeout->deadline secs))) 1488
(write-all-data conn−1474
(build-request-string method parsed headers body)+1489
(build-request-bytes method parsed headers body) 1490
deadline)) 1491
(let ((result (fetch-read-response conn method secs))) 1492
(fetch-safe-close conn)test/integration/run-upload-tests.mjsadded
@@ -0,0 +1,26 @@
+1
// Node 18+; ephemeral loopback listener, no third-party services or credentials.+2
import http from 'node:http';+3
import assert from 'node:assert/strict';+4
import { spawn } from 'node:child_process';+5
const lengths = [];+6
const server = http.createServer(async (req, res) => {+7
const chunks = [];+8
for await (const chunk of req) chunks.push(chunk);+9
const body = Buffer.concat(chunks);+10
lengths.push(body.length);+11
res.writeHead(200, { 'content-length': String(body.length) });+12
res.end(body);+13
});+14
await new Promise((resolve, reject) => {+15
server.once('error', reject); server.listen(0, '127.0.0.1', resolve);+16
});+17
try {+18
const child = spawn('sigil', ['test', '--sgl', '--strict', ...process.argv.slice(2)], {+19
stdio: 'inherit', env: { ...process.env, HTTP_UPLOAD_TEST_PORT: String(server.address().port) },+20
});+21
const timeout = setTimeout(() => child.kill('SIGTERM'), 180_000);+22
const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve); });+23
clearTimeout(timeout);+24
assert.equal(code, 0, 'HTTP suite including binary round-trip passed');+25
assert.deepEqual(lengths, [2 * 1024 * 1024, 0], 'both binary requests reached the server');+26
} finally { server.closeAllConnections(); server.close(); }test/test-upload-bytes.sgladded
@@ -0,0 +1,50 @@
+1
(import (sigil core) (sigil io) (sigil test) (sigil string) (sigil process) (sigil http client))+2
+3
(define (same-bytes? a b)+4
(and (= (bytevector-length a) (bytevector-length b))+5
(let loop ((i 0))+6
(or (= i (bytevector-length a))+7
(and (= (bytevector-u8-ref a i) (bytevector-u8-ref b i)) (loop (+ i 1)))))))+8
(define (sample-bytes size)+9
(let ((bytes (make-bytevector size)))+10
(let loop ((i 0))+11
(when (< i size) (bytevector-u8-set! bytes i (modulo i 256)) (loop (+ i 1)))) bytes))+12
+13
(test-group "binary request assembly"+14
(test "arbitrary bytes bypass UTF-8 encoding"+15
(let* ((body (sample-bytes 256))+16
(wire (build-request-bytes 'PUT (parse-url "http://example.com/object") #{} body))+17
(split (- (bytevector-length wire) 256))+18
(head (utf8->string (bytevector-copy wire 0 split))))+19
(assert-true (string-contains? head "content-length: 256\r\n"))+20
(assert-true (same-bytes? (bytevector-copy wire split) body))))+21
(test "dict framing headers replaced case-insensitively"+22
(let* ((wire (build-request-bytes 'PUT (parse-url "http://example.com/")+23
#{ Content-Length: "999" content-length: "88" Transfer-Encoding: "chunked" } (make-bytevector 0)))+24
(head (string-downcase (utf8->string wire))))+25
(assert-equal (length (string-split head "content-length:")) 2)+26
(assert-true (string-contains? head "content-length: 0\r\n"))+27
(assert-false (string-contains? head "transfer-encoding:"))))+28
(test "alist framing headers replaced case-insensitively"+29
(let ((head (string-downcase (utf8->string+30
(build-request-bytes 'PUT (parse-url "http://example.com/")+31
'(("Content-Length" . "999") ("TRANSFER-ENCODING" . "chunked")) (make-bytevector 0))))))+32
(assert-equal (length (string-split head "content-length:")) 2)+33
(assert-false (string-contains? head "transfer-encoding:"))))+34
(test "string body preserves existing UTF-8 wire format"+35
(let ((url (parse-url "http://example.com/")))+36
(let ((wire (utf8->string (build-request-bytes 'POST url #{} "héllo"))))+37
(assert-true (string-contains? wire "Content-Length: 6\r\n"))+38
(assert-true (string-contains? wire "\r\n\r\nhéllo"))))))+39
+40
(when (getenv "HTTP_UPLOAD_TEST_PORT")+41
(test-group "binary upload network"+42
(test "large binary body and empty body round-trip through HTTP"+43
(let ((url (string-append "http://127.0.0.1:" (getenv "HTTP_UPLOAD_TEST_PORT") "/bytes")))+44
(for-each (lambda (body)+45
(let ((result (http-fetch-bytes 'PUT url headers: #{ content-type: "application/octet-stream" }+46
body: body timeout: 5)))+47
(assert-true result)+48
(assert-equal (dict-ref result status:) 200)+49
(assert-true (same-bytes? body (dict-ref result body:)))))+50
(list (sample-bytes (* 1024 1024 2)) (make-bytevector 0)))))))