http: add http-fetch-bytes — byte-faithful in-memory fetch for relays
http-request utf8->strings the whole body (corrupting non-UTF-8 payloads: wasm, images, archives) and http-download streams to a file, so neither can back a reverse proxy that must relay arbitrary bytes with the response's status + headers intact. Add a public byte-faithful fetch:
(http-fetch-bytes method url (keys: (headers #{}) (body #f) (timeout #f)))
-> #{ status: <int> headers: <ordered alist> body: <bytevector> } | #f * body is a BYTEVECTOR, never decoded to a string;
* headers is an ORDERED alist of (lowercased-name . value) that preserves
order AND duplicates (multiple Set-Cookie etc.), which a dict collapses;
* redirects are NOT followed — a 3xx is returned untouched so the caller
decides (a proxy must relay redirects, not chase them);
* body assembled with a SINGLE allocation, not (apply bytevector-append
<hundreds of chunk args>), which silently returns empty for multi-MB
bodies. NB: read-all-data/parse-http-response behind http-request share
that splat and so truncate large bodies — a separate follow-up, not
touched here to keep this additive + low-risk.Built on the module's own connection layer (connect-to-server + conn-* + find-header-end-bytes) — no internals exported. Framing: Content-Length, chunked (byte-exact dechunk), or Connection: close EOF; idle-based read deadline (resets on data) so a large steady body never times out.
Tests (test-fetch-bytes.sgl, 14): single-alloc assemble incl. a 500-chunk body (splat regression), ordered/duplicate/lowercased header alist, content-length parsing, chunked decode, unreachable -> #f. Full sigil-http suite 158 passed, no regressions.
NOTE for review: sigil.lock re-resolved to run the tests — the branch's lock pinned sigil-test to a different sigil monorepo commit than the toolchain (0.17.19 / b76109c6), a bytecode v9-vs-v10 skew that blocked the test runner. sigil deps update aligned sigil-stdlib/tls/test/crypto/git to the current toolchain + ecosystem versions (same as the slate worktree). Review/adjust during the version bump as you see fit.
sigil.lock | 30 +++++++++++------------
src/sigil/http/client.sgl | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
test/test-fetch-bytes.sgl | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 357 insertions(+), 16 deletions(-)sigil.lockmodified
(lock (package name: "sigil-stdlib" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "c333fbde5ec2d805eddff982952c9993cd441715" ref: "^0.17.10" sha: "b76109c62d04486ecf5c4809faf89102634e97be" package-selector: "sigil-stdlib" version: "0.17.3") version: "0.17.19") (package name: "sigil-socket" url: "codeberg:sigil/sigil-socket" ref: "^0.16.0" (package name: "sigil-tls" url: "codeberg:sigil/sigil-tls" ref: "^0.16.0" sha: "e67c3b00b19bc495e38527203d0a563a8bcd5eab" version: "0.16.2") sha: "baf05e45ed4090406c2990debadabfcd8253d834" version: "0.16.4") (package name: "sigil-json" url: "codeberg:sigil/sigil-json" ref: "^0.16.0" (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412" sha: "b76109c62d04486ecf5c4809faf89102634e97be" package-selector: "sigil-test" version: "0.17.1") version: "0.17.19") (package name: "sigil-test-runner" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412" sha: "b76109c62d04486ecf5c4809faf89102634e97be" package-selector: "sigil-test-runner" version: "0.17.1") version: "0.17.19") (package name: "sigil-lib" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412" sha: "b76109c62d04486ecf5c4809faf89102634e97be" package-selector: "sigil-lib" version: "0.17.1") version: "0.17.19") (package name: "sigil-crypto" url: "codeberg:sigil/sigil-crypto" ref: "^0.16" sha: "a391c4ed73891ac5dc8a6da98b80a340c03f801f" version: "0.16.3") sha: "964eec2fad375834e2b49da454209fabc059ca30" version: "0.16.4") (package name: "sigil-ansi" url: "codeberg:sigil/sigil-ansi" ref: "^0.16.0" (package name: "sigil-git" url: "codeberg:sigil/sigil-git" ref: "^0.16.0" sha: "0da6221050319ee241461b45eaba1a353b7bf0ec" version: "0.16.0") sha: "2d54eb99d3ca0597b3ccd9d4829d312c54721086" version: "0.16.1"))src/sigil/http/client.sglmodified
;; Streaming download http-download ;; Byte-faithful in-memory fetch (status + headers + raw body bytes) http-fetch-bytes ;; API client helpers build-api-url make-response-checker no-body-expected? detect-framing chunked-body-complete? framing-complete?) framing-complete? ;; http-fetch-bytes internals — exported for testing fetch-parse-headers fetch-content-length fetch-assemble fetch-dechunk) (begin size: bytes-written path: dest-path)))))))))) ;; ============================================================ ;; Byte-faithful fetch (raw response bytes) ;; ============================================================ ;; ;; `http-request` utf8->strings the whole body (corrupting any non-UTF-8 ;; payload — wasm, images, archives), and `http-download` streams to a ;; file. `http-fetch-bytes` returns the response IN MEMORY as raw bytes so ;; a caller such as a reverse proxy can relay it byte-for-byte. (define fetch-default-timeout 30) ; seconds (define fetch-read-chunk 65536) (define fetch-max-idle-polls 6000) ;;; Fetch `url` (a `method` symbol, optional request `headers` dict and ;;; string `body`) and return the response as raw bytes: ;;; ;;; #{ status: <integer> ;;; headers: <ordered alist of (lowercased-name . value)> ;;; body: <bytevector> } ;;; ;;; or #f if the upstream could not be reached / the response was ;;; unparseable. Distinct from `http-request` in three ways a byte-exact ;;; relay needs: ;;; ;;; * the body is a BYTEVECTOR, never decoded to a string; ;;; * `headers` is an ORDERED alist that preserves order AND duplicates ;;; (e.g. multiple Set-Cookie), which a dict would silently collapse; ;;; * REDIRECTS ARE NOT FOLLOWED — a 3xx is returned untouched (status + ;;; Location intact) so the caller decides whether to chase it. A ;;; reverse proxy must relay redirects, not follow them; a ;;; redirect-following wrapper can layer on top. ;;; ;;; `timeout` (seconds, or #f -> 30) bounds the TLS connect and is the ;;; IDLE read deadline (it resets whenever bytes arrive, so a large but ;;; steadily-flowing body never times out). Sends `Connection: close`, so ;;; a body with no Content-Length is read to EOF. ;;; ;;; ``` ;;; (let ((r (http-fetch-bytes 'GET "https://example.com/app.wasm"))) ;;; (and r (bytevector-length (dict-ref r body: #f)))) ;;; ``` (define (http-fetch-bytes method url (keys: (headers #{}) (body #f) (timeout #f))) (let ((secs (or timeout fetch-default-timeout))) (guard (e (#t #f)) (let ((parsed (parse-url url))) (and parsed (let ((conn (connect-to-server parsed secs))) (and conn (guard (e (#t (begin (fetch-safe-close conn) #f))) (conn-write conn (build-request-string method parsed headers body)) (let ((result (fetch-read-response conn method secs))) (fetch-safe-close conn) result))))))))) (define (fetch-safe-close conn) (guard (e (#t #f)) (conn-close conn))) ;; Phase 1: accumulate bytes until the CRLFCRLF header terminator (headers ;; are small, so the bounded append here is cheap), then hand off to the ;; body reader. Returns the result dict, or #f if the connection closed ;; before a complete header block arrived. (define (fetch-read-response conn method timeout) (let loop ((buf (make-bytevector 0)) (idle 0) (deadline (+ (current-second) timeout))) (cond ((or (>= (current-second) deadline) (> idle fetch-max-idle-polls)) #f) (else (let ((hidx (find-header-end-bytes buf))) (if hidx (fetch-parse conn method timeout buf hidx) (let ((chunk (guard (e (#t 'err)) (conn-read-bytes conn fetch-read-chunk)))) (cond ((or (eq? chunk 'err) (not chunk) (eof-object? chunk)) #f) ((zero? (bytevector-length chunk)) (loop buf (+ idle 1) deadline)) (else (loop (bytevector-append buf chunk) 0 (+ (current-second) timeout))))))))))) ;; Parse the head, then read the body per its framing. `body0` is whatever ;; body bytes already arrived with the header block. (define (fetch-parse conn method timeout buf hidx) (let* ((head-bytes (bytevector-copy buf 0 hidx)) (body0 (bytevector-copy buf (+ hidx 4) (bytevector-length buf))) (lines (string-split (utf8->string head-bytes) "\r\n")) (status (and (pair? lines) (let ((si (parse-status-line (car lines)))) (and si (cadr si))))) (hdrs (fetch-parse-headers (if (pair? lines) (cdr lines) '()))) (clen (fetch-content-length hdrs)) (te (fetch-header hdrs "transfer-encoding")) (chunked? (and te (string-contains? (string-downcase te) "chunked")))) (and status (let ((bodyv (cond ((no-body-expected? method status) (make-bytevector 0)) ((and clen (not chunked?)) (fetch-body-clen conn timeout body0 clen)) (chunked? (fetch-dechunk (fetch-body-eof conn timeout body0))) (else (fetch-body-eof conn timeout body0))))) #{ status: status headers: hdrs body: bodyv })))) ;; Header lines -> ordered alist of (lowercased-name . value), preserving ;; ORDER and DUPLICATES (a relay must keep multiple Set-Cookie etc.). A ;; line with no colon is skipped. (define (fetch-parse-headers lines) (let loop ((ls lines) (acc '())) (cond ((null? ls) (reverse acc)) (else (let* ((line (car ls)) (cpos (string-index line (lambda (c) (char=? c #\:))))) (if cpos (let ((name (string-downcase (string-trim (substring line 0 cpos)))) (value (string-trim (substring line (+ cpos 1) (string-length line))))) (loop (cdr ls) (cons (cons name value) acc))) (loop (cdr ls) acc))))))) ;; First value for a (lowercased) header name, or #f. (define (fetch-header hdrs name) (let loop ((hs hdrs)) (cond ((null? hs) #f) ((string=? (car (car hs)) name) (cdr (car hs))) (else (loop (cdr hs)))))) (define (fetch-content-length hdrs) (let ((v (fetch-header hdrs "content-length"))) (and v (let ((n (string->number (string-trim v)))) (and (integer? n) (>= n 0) n))))) ;; Identity body of known Content-Length: read until `clen` bytes (or a ;; stall / EOF). Chunks accumulate in a list; assembled once. IDLE deadline ;; resets on data. (define (fetch-body-clen conn timeout body0 clen) (let loop ((chunks (list body0)) (have (bytevector-length body0)) (idle 0) (deadline (+ (current-second) timeout))) (cond ((>= have clen) (fetch-assemble (reverse chunks))) ((or (>= (current-second) deadline) (> idle fetch-max-idle-polls)) (fetch-assemble (reverse chunks))) (else (let ((chunk (guard (e (#t 'err)) (conn-read-bytes conn fetch-read-chunk)))) (cond ((or (eq? chunk 'err) (not chunk) (eof-object? chunk)) (fetch-assemble (reverse chunks))) ((zero? (bytevector-length chunk)) (loop chunks have (+ idle 1) deadline)) (else (loop (cons chunk chunks) (+ have (bytevector-length chunk)) 0 (+ (current-second) timeout))))))))) ;; Chunked or no Content-Length: read to EOF (we send Connection: close). (define (fetch-body-eof conn timeout body0) (let loop ((chunks (list body0)) (idle 0) (deadline (+ (current-second) timeout))) (cond ((or (>= (current-second) deadline) (> idle fetch-max-idle-polls)) (fetch-assemble (reverse chunks))) (else (let ((chunk (guard (e (#t 'err)) (conn-read-bytes conn fetch-read-chunk)))) (cond ((or (eq? chunk 'err) (not chunk) (eof-object? chunk)) (fetch-assemble (reverse chunks))) ((zero? (bytevector-length chunk)) (loop chunks (+ idle 1) deadline)) (else (loop (cons chunk chunks) 0 (+ (current-second) timeout))))))))) ;; Concatenate a list of bytevectors with a SINGLE allocation. NOT ;; `(apply bytevector-append …)`: a multi-MB body arrives as hundreds of ;; chunks, and splatting that many args silently produced an EMPTY result. (define (fetch-assemble chunks) (let ((total (let sum ((cs chunks) (n 0)) (if (null? cs) n (sum (cdr cs) (+ n (bytevector-length (car cs)))))))) (let ((out (make-bytevector total 0))) (let copy ((cs chunks) (pos 0)) (if (null? cs) out (let ((c (car cs))) (bytevector-copy! out pos c 0 (bytevector-length c)) (copy (cdr cs) (+ pos (bytevector-length c))))))))) ;; Byte-exact chunked-transfer decode: strip the hex size lines + CRLFs. ;; Stops at the 0-size terminator or a truncated tail (best-effort). (define (fetch-dechunk bv) (let ((len (bytevector-length bv))) (let loop ((pos 0) (out '())) (if (>= pos len) (fetch-assemble (reverse out)) (let ((line-end (fetch-find-crlf bv pos))) (if (not line-end) (fetch-assemble (reverse out)) (let* ((size (fetch-hex (utf8->string (bytevector-copy bv pos line-end)))) (data (+ line-end 2))) (cond ((or (not size) (<= size 0)) (fetch-assemble (reverse out))) ((> (+ data size) len) (fetch-assemble (reverse out))) (else (loop (+ data size 2) (cons (bytevector-copy bv data (+ data size)) out))))))))))) (define (fetch-find-crlf bv pos) (let ((len (bytevector-length bv))) (let loop ((i pos)) (cond ((> (+ i 2) len) #f) ((and (= (bytevector-u8-ref bv i) 13) (= (bytevector-u8-ref bv (+ i 1)) 10)) i) (else (loop (+ i 1))))))) (define (fetch-hex s) (let* ((t (string-trim s)) (semi (string-index t (lambda (c) (char=? c #\;)))) (hx (if semi (substring t 0 semi) t)) (len (string-length hx))) (if (= len 0) #f (let loop ((i 0) (acc 0)) (if (>= i len) acc (let ((d (fetch-hex-digit (string-ref hx i)))) (if d (loop (+ i 1) (+ (* acc 16) d)) #f))))))) (define (fetch-hex-digit ch) (cond ((and (char>=? ch #\0) (char<=? ch #\9)) (- (char->integer ch) 48)) ((and (char>=? ch #\a) (char<=? ch #\f)) (+ 10 (- (char->integer ch) 97))) ((and (char>=? ch #\A) (char<=? ch #\F)) (+ 10 (- (char->integer ch) 65))) (else #f))) ;; ============================================================ ;; API Client Helpers ;; ============================================================test/test-fetch-bytes.sgladded
;;; Tests for (sigil http client) http-fetch-bytes — the byte-faithful fetch;;; and its internals (assembly, header alist, chunked decode, content-length).(import (sigil test) (sigil core) (sigil io) (sigil string) (sigil time) (sigil socket) (sigil http client));; Content-compare two bytevectors (assert-equal on bytevectors is not;; guaranteed structural, so compare bytes explicitly).(define (bv=? a b) (and (= (bytevector-length a) (bytevector-length b)) (let loop ((i 0)) (cond ((>= i (bytevector-length a)) #t) ((= (bytevector-u8-ref a i) (bytevector-u8-ref b i)) (loop (+ i 1))) (else #f)))));; ============================================================;; fetch-assemble — single-allocation concat;; ============================================================;; The regression that motivated this: a multi-MB body arrives as hundreds of;; chunks, and `(apply bytevector-append <hundreds of args>)` silently returned;; an EMPTY bytevector. The single-allocation assemble must produce the full;; length with correct content regardless of chunk count.(test-group "fetch-assemble" (test "empty list -> empty bytevector" (assert-equal (bytevector-length (fetch-assemble '())) 0)) (test "single chunk preserved" (assert-true (bv=? (fetch-assemble (list (string->utf8 "hello"))) (string->utf8 "hello")))) (test "chunks concatenated in order" (assert-true (bv=? (fetch-assemble (list (string->utf8 "ab") (string->utf8 "c") (string->utf8 "def"))) (string->utf8 "abcdef")))) (test "hundreds of chunks assemble to full length (no splat truncation)" (let* ((chunk (make-bytevector 1024 65)) ; 1 KB of 'A' (n 500) (chunks (let build ((i 0) (acc '())) (if (>= i n) acc (build (+ i 1) (cons chunk acc))))) (out (fetch-assemble chunks))) (assert-equal (bytevector-length out) (* n 1024)) (assert-equal (bytevector-u8-ref out 0) 65) (assert-equal (bytevector-u8-ref out (- (* n 1024) 1)) 65))));; ============================================================;; fetch-parse-headers — ordered alist, dups + order preserved, lowercased;; ============================================================(test-group "fetch-parse-headers" (test "name lowercased, value trimmed" (let ((h (fetch-parse-headers (list "Content-Type: text/html" "Content-Length: 5")))) (assert-equal (length h) 2) (assert-equal (car (car h)) "content-type") (assert-equal (cdr (car h)) "text/html") (assert-equal (cdr (cadr h)) "5"))) (test "duplicate headers preserved, in order" (let ((h (fetch-parse-headers (list "Set-Cookie: a=1" "Set-Cookie: b=2")))) (assert-equal (length h) 2) (assert-equal (cdr (car h)) "a=1") (assert-equal (cdr (cadr h)) "b=2"))) (test "line without a colon is skipped" (assert-equal (length (fetch-parse-headers (list "garbage-no-colon" "X: 1"))) 1)));; ============================================================;; fetch-content-length;; ============================================================(test-group "fetch-content-length" (test "present -> integer" (assert-equal (fetch-content-length (list (cons "content-length" "42"))) 42)) (test "absent -> #f" (assert-false (fetch-content-length (list (cons "content-type" "x"))))) (test "non-numeric -> #f" (assert-false (fetch-content-length (list (cons "content-length" "abc"))))));; ============================================================;; fetch-dechunk — byte-exact chunked-transfer decode;; ============================================================(test-group "fetch-dechunk" (test "single chunk" (assert-true (bv=? (fetch-dechunk (string->utf8 "5\r\nhello\r\n0\r\n\r\n")) (string->utf8 "hello")))) (test "multiple chunks concatenated" (assert-true (bv=? (fetch-dechunk (string->utf8 "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n")) (string->utf8 "hello world")))) (test "hex chunk size" (assert-true (bv=? (fetch-dechunk (string->utf8 "a\r\n0123456789\r\n0\r\n\r\n")) (string->utf8 "0123456789")))));; ============================================================;; http-fetch-bytes — unreachable upstream returns #f (no server needed);; ============================================================(test-group "http-fetch-bytes" (test "connection refused -> #f" (assert-false (http-fetch-bytes 'GET "http://127.0.0.1:9/" timeout: 2))))