HTTP through the platform stack (headless capability crate)

Status: implemented as day-part-http (in parts/), a headless day-ecosystem crate with no UI Piece: request/response HTTP (plus streaming downloads) through each platform’s own networking stack: NSURLSession on macOS/iOS, OkHttp on Android (the platform’s own frozen engine, current; see the engine note below), WinHTTP on Windows, the browser’s fetch() on the web (web-dom, async entry points only; see the web tier below), with a bundled ureq + rustls fallback on Linux and HarmonyOS. Verified end-to-end with a local-server test suite on the real Apple half (macOS) and the real fallback half (Linux), and live on macOS/iOS-sim/Android-emulator/browser via the showcase walkthrough and Day Skies’ Open-Meteo fetch.

Why the platform stack instead of a Rust HTTP crate: the OS already knows the things an app can’t easily discover (system proxies and PAC scripts, per-network VPN routing, Low Data Mode, enterprise/MDM certificate stores, user-installed CAs). Apps that fetch through the platform inherit all of it, and the native targets bundle no TLS code at all (rustls compiles only into the cfg-gated Linux/OHOS fallback).

Authoring

use day_part_http::{Request, fetch};

// Blocking — call it off the UI thread (a worker thread, or day::task's pool).
let resp = fetch(&Request::get("https://api.example.com/data.json"))?;
if (200..300).contains(&resp.status) {
    let body: MyData = serde_json::from_slice(&resp.body)?;
}

Request is a builder: get/post/put/delete/patch/head(url), .header(k, v) (duplicates allowed), .body(Vec<u8>), .timeout(Duration), .allow_expensive(bool) / .allow_constrained(bool). Response { status, headers, body } adds text() (lossy UTF-8) and a case-insensitive header(name).

Two contract points that differ from ureq-style clients:

  • 4xx/5xx are Ok. An HTTP error status is a response (resp.status == 404), not an HttpError. Errors are transport-level only: BadUrl, Timeout, Dns, Connect, Tls, Io, Unsupported (the enum is #[non_exhaustive]).
  • timeout bounds progress, not the transfer. It covers connecting, awaiting the response head, and idle gaps; a multi-minute download that keeps moving is never cut off. Default 30 s.

Async + the Setter idiom

let status: Signal<String> = Signal::new(String::new());
let done = status.setter(); // Copy + Send; hops to the UI thread itself
day_part_http::fetch_async(Request::get(url), move |result| {
    // Runs on an UNSPECIFIED BACKGROUND thread (URLSession's delegate queue on Apple,
    // a spawned thread elsewhere). Never touch UI state directly here.
    if let Ok(resp) = result {
        done.set(resp.text()); // no-ops harmlessly if the page was disposed meanwhile
    }
});

fetch_async(req, on_done) completes on a background thread by design: the crate never calls day_reactive::on_main (which requires an installed backend poster and would break plain-main programs and cargo test). Capturing a Setter in on_done is the standard delivery idiom (DESIGN §4.5); it marshals to the UI thread itself and absorbs late deliveries after disposal. The showcase’s Platform services page demonstrates it twice: a deterministic local fetch (a one-shot loopback server natively; on web-dom, where a tab can host no listener, the dev server’s same-origin /day-http-ok echo endpoint with identical bodies), and a URL checker (type any http(s) URL, tap Check) that prints the response headers and body size. resp.headers is the full header list, resp.header(name) the case-insensitive lookup.

Feeding remote-image

day-piece-remote-image stays fetch-agnostic (the app owns the bytes signal), but gains the one-liner for the common case:

remote_image_url("https://example.com/logo.png").rounded(8.0)

remote_image_url fetches once through day-part-http and pushes 2xx bytes into the piece’s own signal via a Setter; failures leave the placeholder color showing.

Downloads and streaming

// Straight to disk — the body never sits in memory.
let dl = fetch_to_file(&Request::get(apk_url), &dest)?;   // Download { status, headers, bytes_written }

// Full control — progress, cancellation, incremental hashing:
struct MySink { /* progress handle, hasher, file … */ }
impl StreamSink for MySink {
    fn head(&mut self, status: u16, headers: &[(String, String)]) -> bool {
        status == 200 // returning false aborts before any chunk
    }
    fn chunk(&mut self, data: &[u8]) -> Result<(), HttpError> {
        /* hash + write + report; return Err to cancel mid-body */ Ok(())
    }
}
let dl = fetch_streamed(&Request::get(url), &mut MySink { .. })?;

fetch_to_file has an async twin (fetch_to_file_async). App Fair’s downloader is the shipped reference: a StreamSink that hashes as it writes, reports progress, honors a cancel flag, and implements HTTP Range resume by deciding append-vs-restart in head().

Per-platform native realization

OSAPIdependency
macOS + iOSNSURLSession (shared ephemeral session; per-request delegate session for streaming)objc2-foundation, shared apple.rs
AndroidOkHttp 4.12 via the part-owned DayHttp.java shim; one byte[] envelope per callday-android + [package.metadata.day.android] (staged Java + the okhttp Gradle coordinate)
WindowsWinHTTP (winhttp.dll, resolved dynamically; WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY)raw FFI (runtime lookup)
Web (web-dom)the browser’s fetch() via the day-dom shim’s day_dom_http_* imports (request-id + AbortController); async entry points onlyfetch/fetch_to_file/fetch_streamed return Unsupportedweb.rs (wasm32; requires the day-dom host page, the day-part-prefs pattern)
Linuxureq 3 + rustls (the only tier that bundles TLS)ureq, fallback.rs
HarmonyOSureq 3 + rustls — the OSS 5.1 NDK has no HTTP C API (HMS_Rcp_* is HarmonyOS-NEXT-SDK-only)ureq, same fallback.rs
unknown/mockcatch-all: every call returns HttpError::Unsupported

tier() reports which of the three tiers the compiled target uses (NativeStack, RustFallback, or Unavailable), so an app (or a doc table) never has to guess:

  • NativeStack: system proxy + PAC, VPN routing, platform TLS + certificate stores all apply. The web is this tier: the browser IS the platform stack (proxies, TLS, certificate store, HTTP/2/3 all come from it). But it is async-only: on the single browser thread a blocking wait would starve the event loop the completion needs (docs/web.md), so the blocking entry points return Unsupported while fetch_async/fetch_future work in full. Two web-only realities apply: CORS governs cross-origin requests (and limits which response headers are visible), and browser-controlled headers (Host, Cookie, Origin, …) cannot be set from a request.
  • RustFallback: correct HTTP(S) via rustls + webpki roots, but system awareness is limited to the http_proxy/https_proxy/no_proxy environment variables (no PAC, no desktop proxy settings).
  • Unavailable: every call fails with Unsupported (the mock/unknown-target posture).

Error mapping

HttpErrorApple (NSURLErrorDomain)Android (exception)Windows (ERROR_WINHTTP_*)web (fetch rejection)fallback (ureq)
Timeout−1001SocketTimeoutException12002AbortError from the timeout timerTimeout
Dns−1003, −1006UnknownHostException12007— (see below)HostNotFound
Connect−1004, −1009ConnectException12029, 12030— (see below)ConnectionFailed
Tls(msg)−1200…−1206SSLExceptionsecure-failure set (12157, 12175, …)— (see below)Tls
BadUrl−1000, −1002IllegalArgumentException (URL rejected)12005, 12006new URL(...) rejectsBadUri
Cancelled−999Call.isCanceled() (sentinel −7)— (discard tier)AbortError from day_dom_http_abort— (discard tier)
Io(msg)anything elseanything elseanything elseanything else (see below)anything else

The web column is deliberately coarse: browsers collapse DNS, connect, TLS, and CORS failures into one opaque TypeError (an anti-fingerprinting measure), so every network-level failure surfaces as Io with the browser’s message; Dns/Connect/Tls never occur on this tier.

Options: applied vs accepted

Options that only some platforms can realize are documented, not silently dropped:

optionAppleAndroidWindowswebfallback
.timeouttimeoutInterval (idle timer)OkHttp connect/read/write per-phase bounds (no callTimeout)per-operation WinHttpSetTimeoutsan abort timer over connect + response head (body phase uncapped — fallback parity; fetch has no native timeout)resolve/connect/send/response-head timeouts (body phase uncapped)
.allow_expensive / .allow_constrainednative (allowsExpensiveNetworkAccess / allowsConstrainedNetworkAccess, Low Data Mode)advisory onlyadvisory onlyadvisory onlyadvisory only
.headeras givenas givenas givenbrowser-controlled names (Host, Cookie, Origin, …) are ignored per the fetch specas given
redirectsfollowed (no opt-out in v1)followedfollowedfollowedfollowed

App Transport Security (iOS/macOS) and Android cleartext

Both mobile platforms restrict plain http:// by default; the platform stack enforces the platform’s policy, which is a feature, but needs two notes:

  • ATS (Apple): NSURLSession refuses non-HTTPS URLs unless the app’s Info.plist carries an exception (NSAppTransportSecurity). Loopback IP fetches (http://127.0.0.1:…) are exempt; the showcase’s local demo needs no plist changes. For a real cleartext host, add a scoped NSExceptionDomains entry; don’t reach for NSAllowsArbitraryLoads.
  • Android cleartext: blocked app-wide since targetSdk 28, including loopback. The showcase scaffold ships a network_security_config.xml permitting cleartext to 127.0.0.1 only (plus the android:networkSecurityConfig manifest attribute); scope any real exception the same way.

The fallback tier performs no such policy enforcement (ureq happily fetches http://), another reason tier() exists.

Threading

fetch/fetch_to_file/fetch_streamed block the calling thread and MUST run off the UI thread (spawn, or day::task). On Android the calling thread is attached to the JVM via day_android::with_env; class resolution works from any Rust-spawned thread because day-android’s dfind/dcall_static fall back to the app ClassLoader cached at init (a bare JNI FindClass on a native thread sees only the system loader). fetch_async/fetch_to_file_async are fire-and-forget wrappers that deliver on a background thread; see the Setter idiom above.

On the web there is exactly one thread, and it must never wait: the blocking calls return Unsupported there, and fetch_async’s completion arrives on that sole (UI) thread from the browser event loop. Both delivery idioms work unchanged: a captured Setter detects it is already on the UI thread, and fetch_future under day::task resumes there anyway.

Async and cancellation

// Await-style (docs/async.md): starts immediately; resumes on the UI thread under day::task,
// so the readout is a plain signal write — no Setter.
day::task(async move {
    match day_part_http::fetch_future(req).await {
        Ok(resp) => status.set(format!("{} · {} bytes", resp.status, resp.body.len())),
        Err(e) => status.set(format!("error: {e}")),
    }
});

fetch_future(req) is oneshot plumbing over fetch_async’s completion: any executor can await it (day::task, or a test’s ~25-line block_on in tests/http.rs). The future is the cancel grip; dropping it cancels the request where the platform can:

tierdrop-cancel
Applenative — NSURLSessionTask.cancel(); a completion that beats the observer maps NSURLErrorCancelledHttpError::Cancelled
Androidnative — OkHttp Call.cancel() via a cancel-token registry in DayHttp.java (sentinel −7 → Cancelled). One microsecond-scale race is accepted: a drop that lands between the worker starting and the Java-side registration degrades to discard-only (the token registry stays leak-free by pairing every put with a finally-remove — no tombstones)
Webnative — the shim’s per-request AbortController.abort() (day_dom_http_abort), rejecting the in-flight fetch (or its body read) with AbortErrorCancelled
Windows / fallbackdiscard-only — the request runs out on its worker thread under its timeout and the result is dropped

Aborting a day::task that awaits a fetch_future (or superseding a day::reactive::Resource fetch) drops the future and rides the same rail. The showcase’s URL checker aborts its previous in-flight check on re-tap, a live demo of drop-cancel.

The Android engine (OkHttp)

The Android half moved from java.net.HttpURLConnection to OkHttp 4.12 (2026-07). AOSP’s own HttpURLConnection has been a frozen OkHttp fork since Android 4.4, so this upgrades the same lineage to a current engine rather than changing philosophy: the system ProxySelector, VPN routing, network security config (OkHttp checks NetworkSecurityPolicy for cleartext), and the platform TrustManager/user CA store all still apply. What the engine adds: HTTP/2 (over TLS via ALPN), PATCH (the classic HttpURLConnection gap; Request::patch now works on every platform), and thread-safe per-call cancellation. Costs and behavior deltas: the okhttp + okio + kotlin-stdlib Gradle dependencies add roughly 1.5–2.5 MB pre-R8 (well under 1 MB after shrinking; OkHttp ships its own proguard rules); cross-protocol redirects (https→http) are now followed, matching the other platforms; response headers now arrive in arrival order with duplicates preserved (better fidelity than the old Map-shaped API). The coordinate rides the part’s own [package.metadata.day.android] gradle-dependencies, the day-piece-lottie mechanism.

v2 notes (deliberately out of scope)

Cookies, multipart, upload streaming, websockets, no_redirect (needs an Apple session delegate to honor), cancellation for fetch_to_file/fetch_streamed futures (today StreamSink cancels mid-body and covers the download cases), and a native HarmonyOS half via a framework-owned ArkTS registerHttp bridge (the registerOpenUrl pattern) if the Remote Communication Kit’s C API reaches the OSS SDK.

What it shows about the extension system

Like day-part-network, a headless part: cfg(target_os) halves behind one mod imp, per-target dependencies, part-owned Java staged via [package.metadata.day.android] (which also contributes android.permission.INTERNET), no framework changes. It is the first part with an async surface and background completion threads (the shape DESIGN §4.5 blesses) and the first whose Java runs on Rust-spawned threads, which is what motivated the app-ClassLoader fallback in day-android’s DayEnv helpers. The web arm rides the day-part-prefs precedent (part-declared extern "C" imports the day-dom shim implements), extended with the shim’s request-id callback pattern for its async completions; it is the first part to complete back into wasm.