Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
987d5d9
S1: update
passcod Aug 17, 2026
5ef7a5f
S1: update rust-api-shapes.html
passcod Aug 18, 2026
c9b3673
S1: update rust-api-shapes.html
passcod Aug 18, 2026
3872c2e
S1: update rust-api-shapes.html
passcod Aug 18, 2026
a028378
S1: update 3 specs, update overview.md
passcod Aug 18, 2026
f086b5d
S1: update client-api.md
passcod Aug 18, 2026
52b5011
S1: update 3 specs, update rust-api-shapes.html
passcod Aug 18, 2026
eb4bc01
S1: update
passcod Aug 18, 2026
ae82b63
S1: scaffold the Cargo workspace
passcod Aug 24, 2026
196df43
S1: split the error core out of the napi layer
passcod Aug 24, 2026
39c943f
S1: extract web-faith-integrity
passcod Aug 24, 2026
ee75c48
S1: extract web-faith-encoding
passcod Aug 24, 2026
75a85bb
S1: extract web-faith-cookies
passcod Aug 24, 2026
f64c5f8
S1: extract web-faith-dns
passcod Aug 24, 2026
d32a47c
S1: extract web-faith-conn-tracker
passcod Aug 24, 2026
cbb4cfc
S1: extract web-faith-alt-svc
passcod Aug 24, 2026
266e1b2
S1: record the split's progress and decisions in the plan
passcod Aug 24, 2026
e1daad2
S1: move the body, timing, and retry machinery into web-faith
passcod Aug 24, 2026
1ea452d
S1: move the client-building machinery into web-faith
passcod Aug 24, 2026
a93fad1
S1: record step 8's progress and what the Agent inversion needs
passcod Aug 24, 2026
8e2f531
S1: write the component crates' docs for an external reader
passcod Aug 24, 2026
07c8626
S1: state each environment-variable section's surfaces in place
passcod Aug 24, 2026
34a28fa
S1: drop the editorialising from the environment spec's opening
passcod Aug 24, 2026
d65fb84
S1: name the Rust surface's extra roots by what they are, not by the …
passcod Aug 24, 2026
449301f
S1: drop the Rust aside from the certificate-validation section
passcod Aug 24, 2026
3459f7e
S1: drop the crate-split aside from the error code contract
passcod Aug 24, 2026
37785ca
S1: name both surfaces in the overview's opening line
passcod Aug 24, 2026
de98293
S1: list the two public surfaces in the overview
passcod Aug 24, 2026
3ec14fa
S1: cut the one-implementation boilerplate from the overview
passcod Aug 24, 2026
cc4b750
S1: keep the overview to stance, and name shared options rather than …
passcod Aug 24, 2026
cc27499
S1: record the remaining spec sites that spell shared behaviour in on…
passcod Aug 24, 2026
ea11858
S1: sequence the spec sweep as the closing step
passcod Aug 24, 2026
66825a3
S1: open the Rust API spec on the agent rather than a count of its nouns
passcod Aug 24, 2026
ef83a4a
S1: move the warm-up argument parsing into web-faith
passcod Aug 24, 2026
a261bbd
S1: move the agent's counters into web-faith
passcod Aug 24, 2026
807ae5b
S1: invert the agent, so the client owns it and the binding wraps it
passcod Aug 24, 2026
922c5fb
S1: record the agent inversion as done, and what step 8 has left
passcod Aug 24, 2026
d7135f3
S1: move the response's own machinery into web-faith
passcod Aug 24, 2026
1fb5e29
S1: invert the response, so the client owns it and the binding wraps it
passcod Aug 24, 2026
3b0231c
S1: move the request path into web-faith
passcod Aug 24, 2026
100720e
S1: finish standing up the client
passcod Aug 24, 2026
5f443c3
S1: write the client's own docs in the client's own terms
passcod Aug 24, 2026
95464f9
S1: integrity is always built, by design rather than by expedience
passcod Aug 24, 2026
5b69631
S1: update 9 files
passcod Aug 24, 2026
5f2a8ab
S1: fold SRI into the client, and untangle component from crate from …
passcod Aug 24, 2026
267508e
S1: cut the feature explainer from the Rust distribution spec
passcod Aug 24, 2026
eba1c38
S1: the client owns the user agent it sends
passcod Aug 24, 2026
0bb369c
S1: give the client's response its own reading API
passcod Aug 24, 2026
c96538d
S1: a response feeds the ecosystem as an http::Response
passcod Aug 24, 2026
e8a9733
S1: record step 9's progress and what the agent builder needs
passcod Aug 24, 2026
03740eb
S1: the client validates its own options, so Agent::new() exists
passcod Aug 24, 2026
2105ad3
S1: record the clone-sharing gap the new agent test found
passcod Aug 24, 2026
dd9b7e5
S1: update 4 files
passcod Aug 25, 2026
9787484
S1: Agent::builder(), so an agent is built a setting at a time
passcod Aug 25, 2026
08f35ec
S1: update 6 files
passcod Aug 25, 2026
e97451b
Update plan
passcod Aug 25, 2026
8defbc4
S1: split the oversized source files into modules
passcod Aug 25, 2026
1bdf705
S1: a feature per capability, starting with cookies and DNS
passcod Aug 25, 2026
d3a9b80
S1: features for the cache, codings, and connection tracking
passcod Aug 25, 2026
142f56c
S1: gate the HTTP/3 options, and make the TLS backend a choice
passcod Aug 25, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ jobs:
# cargo-zigbuild is invoked directly rather than via `napi build -x`
# because the napi CLI can't pass a glibc version suffix; we target
# a glibc 2.35 floor. RUSTFLAGS for the same reason as musl below.
build: RUSTFLAGS="--cfg reqwest_unstable" cargo zigbuild --target aarch64-unknown-linux-gnu.2.35 --release && cp target/aarch64-unknown-linux-gnu/release/libfaith.so faith.linux-arm64-gnu.node
build: RUSTFLAGS="--cfg reqwest_unstable" cargo zigbuild -p web-faith-napi --target aarch64-unknown-linux-gnu.2.35 --release && cp target/aarch64-unknown-linux-gnu/release/libfaith.so faith.linux-arm64-gnu.node
- host: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
build: RUSTFLAGS="--cfg reqwest_unstable" cargo zigbuild --target armv7-unknown-linux-gnueabihf.2.35 --release && cp target/armv7-unknown-linux-gnueabihf/release/libfaith.so faith.linux-arm-gnueabihf.node
build: RUSTFLAGS="--cfg reqwest_unstable" cargo zigbuild -p web-faith-napi --target armv7-unknown-linux-gnueabihf.2.35 --release && cp target/armv7-unknown-linux-gnueabihf/release/libfaith.so faith.linux-arm-gnueabihf.node
- host: ubuntu-latest
target: aarch64-linux-android
build: npm run build -- --target aarch64-linux-android
Expand Down
372 changes: 372 additions & 0 deletions .workhorse/design/mockups/s1/rust-api-shapes.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,372 @@
<!-- spec: (none yet) — proposed Rust API surface for the web-faith crate -->
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<title>Rust API shape</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 48px 32px 96px;
background: #f8f7f4;
color: #1c1917;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.6;
}
.page { max-width: 1180px; margin: 0 auto; }

h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
.lede { color: #57534e; margin: 0 0 40px; max-width: 740px; }

.panel {
background: #ffffff;
border: 1px solid #e7e5e4;
border-radius: 12px;
padding: 28px;
margin-bottom: 24px;
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.04);
}
h2 { font-size: 19px; font-weight: 600; margin: 0 0 4px; letter-spacing: -0.01em; }
.panel-note { color: #57534e; margin: 0 0 24px; max-width: 800px; }

.cols-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.cols-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 1000px) { .cols-3 { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .cols-2 { grid-template-columns: 1fr; } }

.label {
font-size: 12px;
font-weight: 600;
color: #57534e;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
pre {
margin: 0;
background: #fafaf9;
border: 1px solid #e7e5e4;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.65;
color: #1c1917;
}
.k { color: #57534e; font-weight: 600; }
.s { color: #4d7c0f; }
.c { color: #a8a29e; }
.a { color: #c2410c; font-weight: 600; }

h2.section { font-size: 21px; margin: 48px 0 16px; letter-spacing: -0.015em; }
table {
width: 100%;
border-collapse: collapse;
background: #ffffff;
border: 1px solid #e7e5e4;
border-radius: 12px;
overflow: hidden;
}
th, td {
text-align: left;
padding: 12px 16px;
border-bottom: 1px solid #f5f5f4;
vertical-align: top;
}
th {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #57534e;
background: #fafaf9;
}
td { color: #57534e; font-size: 14px; }
td:first-child { color: #1c1917; font-weight: 500; white-space: nowrap; }
tr:last-child td { border-bottom: none; }
code {
font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px;
background: #f5f5f4;
padding: 1px 5px;
border-radius: 4px;
color: #1c1917;
}
.dropped {
background: #ffffff;
border: 1px solid #e7e5e4;
border-radius: 12px;
padding: 20px 28px;
margin-top: 16px;
}
.dropped ul { margin: 8px 0 0; padding-left: 18px; color: #57534e; }
.dropped li { margin-bottom: 6px; }
</style>
</head>
<body>
<div class="page">

<h1>Rust API shape</h1>
<p class="lede">
<code>web-faith</code> has one noun and one verb. An <code>Agent</code> owns the pool, jar, cache, and
resolver; <code>fetch</code> is how every request goes out. Defaults match the Node surface: redirects
followed and HTTP/3 upgrade on, cookie jar and HTTP cache opted into.
</p>

<div class="panel">
<h2>The whole surface</h2>
<p class="panel-note">
<code>fetch</code> takes a URL or a prebuilt <code>Request</code> and returns a builder.
The builder is a future, so a bare call awaits directly and a configured one awaits after its options.
There is no <code>send()</code> and no second entry point.
</p>

<div class="cols-3">
<div>
<div class="label">Fetch some JSON</div>
<pre><span class="k">use</span> web_faith::<span class="a">Agent</span>;

<span class="k">let</span> agent = <span class="a">Agent</span>::new()?;

<span class="k">let</span> things: <span class="k">Vec</span>&lt;Thing&gt; = agent
.<span class="a">fetch</span>(<span class="s">"https://api.example/things"</span>)
.<span class="k">await</span>?
.json()
.<span class="k">await</span>?;</pre>
</div>

<div>
<div class="label">Configured POST</div>
<pre><span class="k">let</span> agent = <span class="a">Agent</span>::builder()
.user_agent(<span class="s">"myapp/1.0"</span>)
.cache(Cache::disk(<span class="s">"/var/cache/app"</span>))
.cookies(<span class="k">true</span>)
.build()?;

<span class="k">let</span> res = agent
.<span class="a">fetch</span>(<span class="s">"https://api.example/things"</span>)
.method(Method::POST)
.header(<span class="s">"authorization"</span>, tok)
.integrity(<span class="s">"sha384-oqVuAfXR"</span>)
.cache_mode(CacheMode::NoStore)
.timeout(Duration::from_secs(<span class="s">5</span>))
.json(&amp;thing)
.<span class="k">await</span>?;</pre>
</div>

<div>
<div class="label">Stream the body</div>
<pre><span class="k">let</span> res = agent.<span class="a">fetch</span>(url).<span class="k">await</span>?;

<span class="k">let</span> <span class="k">mut</span> body = res.body_stream();
<span class="k">while</span> <span class="k">let</span> <span class="k">Some</span>(chunk) = body.next().<span class="k">await</span> {
sink.write_all(&amp;chunk?).<span class="k">await</span>?;
}

<span class="k">let</span> trailers = res.trailers().<span class="k">await</span>;
<span class="k">let</span> timing = res.timing().<span class="k">await</span>;</pre>
</div>
</div>
</div>

<div class="panel">
<h2>Building a request once</h2>
<p class="panel-note">
A <code>Request</code> is the built, inert form. Because <code>fetch</code> accepts one and still returns a
builder, a request can be prepared once and tweaked at each call site, or sent unchanged across
two agents. Cloning follows the body: a buffered body clones, a stream body cannot, so it is
<code>try_clone</code> rather than <code>Clone</code>.
</p>

<div class="cols-2">
<div>
<div class="label">Prepare, then vary</div>
<pre><span class="k">let</span> probe = <span class="a">Request</span>::new(<span class="s">"https://api.example/health"</span>)
.header(<span class="s">"accept"</span>, <span class="s">"application/json"</span>)
.timeout(Duration::from_secs(<span class="s">2</span>))
.build()?;

<span class="k">for</span> region <span class="k">in</span> regions {
<span class="k">let</span> <span class="k">Some</span>(req) = probe.<span class="a">try_clone</span>() <span class="k">else</span> { <span class="k">break</span> };

<span class="k">let</span> res = agent
.<span class="a">fetch</span>(req)
.header(<span class="s">"x-region"</span>, region)
.<span class="k">await</span>?;
}</pre>
</div>

<div>
<div class="label">Same request, two agents</div>
<pre><span class="k">let</span> req = <span class="a">Request</span>::new(url)
.method(Method::POST)
.json(&amp;payload)?
.build()?;

<span class="k">let</span> <span class="k">Some</span>(copy) = req.<span class="a">try_clone</span>() <span class="k">else</span> {
<span class="k">return</span> <span class="k">Err</span>(Error::NotCloneable);
};

<span class="k">let</span> (primary, mirror) = tokio::join!(
live_agent.<span class="a">fetch</span>(req),
audit_agent.<span class="a">fetch</span>(copy),
);</pre>
</div>
</div>
</div>

<div class="panel">
<h2>Agent lifecycle</h2>
<p class="panel-note">
An agent is cheap to clone and every clone names the same pool, so <code>close</code> acts on the shared
agent rather than on the handle it was called through. Requests already issued run to completion, even
ones no work has started on; a request issued afterwards fails with the closed-agent error.
</p>

<div class="cols-2">
<div>
<div class="label">Shutting down</div>
<pre><span class="k">let</span> agent = Agent::new()?;
<span class="k">let</span> handle = agent.clone();

tokio::spawn(<span class="k">async</span> <span class="k">move</span> {
handle.<span class="a">fetch</span>(url).<span class="k">await</span>
});

<span class="c">// releases the pool, resolver, and probes</span>
<span class="c">// for every clone, not just this one</span>
agent.<span class="a">close</span>();</pre>
</div>

<div>
<div class="label">Acting on a live agent</div>
<pre>agent.<span class="a">network_changed</span>();

<span class="k">let</span> stats = agent.stats();
<span class="k">let</span> conns = agent.connections();
<span class="k">let</span> resolvers = agent.resolvers();
<span class="k">let</span> jar = agent.cookies();

agent.prefetch_dns(<span class="s">"api.example"</span>).<span class="k">await</span>?;
agent.preconnect(<span class="s">"https://api.example"</span>).<span class="k">await</span>?;</pre>
</div>
</div>
</div>

<h2 class="section">Response</h2>
<table>
<tbody>
<tr>
<td>Fetch surface</td>
<td><code>status</code>, <code>status_text</code>, <code>ok</code>, <code>headers</code>, <code>url</code>,
<code>redirected</code>, <code>kind</code>, <code>body_used</code></td>
</tr>
<tr>
<td>Reading</td>
<td><code>text()</code>, <code>json()</code>, <code>bytes()</code>, <code>body_stream()</code>,
<code>to_file()</code>, <code>discard()</code>. Reading consumes the body and a second read is an
error, following the fetch standard rather than reqwest's owned-response model.</td>
</tr>
<tr>
<td>Faith additions</td>
<td><code>peer</code>, <code>version</code>, <code>trailers()</code>, <code>timing()</code></td>
</tr>
<tr>
<td>Errors</td>
<td>One <code>Error</code> type carrying the existing kinds, each reporting the same code the Node
binding does.</td>
</tr>
<tr>
<td>Cancellation</td>
<td>Dropping the future cancels the request. <code>timeout</code> stays an option for the deadline case.</td>
</tr>
<tr>
<td>Runtime</td>
<td>Tokio, as today.</td>
</tr>
</tbody>
</table>

<h2 class="section">Ecosystem types</h2>
<p class="lede" style="margin-bottom: 24px;">
The <code>http</code> crate's types are canonical wherever one exists, so a Faith request composes with
tower, hyper, and axum code without a shim. Setters stay fetch-flavoured by taking anything that
converts, so a string literal works where JS would pass a string.
</p>
<table>
<thead>
<tr><th style="width: 22%">Concept</th><th style="width: 26%">Canonical type</th><th>What setters accept</th></tr>
</thead>
<tbody>
<tr>
<td>Method</td>
<td><code>http::Method</code></td>
<td><code>.method(impl TryInto&lt;Method&gt;)</code>, so <code>"POST"</code> and <code>Method::POST</code> both work</td>
</tr>
<tr>
<td>Header</td>
<td><code>http::HeaderName</code>, <code>http::HeaderValue</code></td>
<td><code>.header(impl TryInto&lt;HeaderName&gt;, impl TryInto&lt;HeaderValue&gt;)</code></td>
</tr>
<tr>
<td>Header set</td>
<td><code>http::HeaderMap</code></td>
<td><code>.headers(impl IntoIterator&lt;Item = (K, V)&gt;)</code>, which covers a <code>HeaderMap</code>, a
<code>Vec</code> of pairs, and an array of string pairs</td>
</tr>
<tr>
<td>Status</td>
<td><code>http::StatusCode</code></td>
<td>Read-only. <code>ok</code> and <code>status_text</code> derive from it</td>
</tr>
<tr>
<td>Version</td>
<td><code>http::Version</code></td>
<td>Read-only, replacing the Node binding's string</td>
</tr>
<tr>
<td>URL</td>
<td><code>url::Url</code></td>
<td><code>impl TryInto&lt;Url&gt;</code>, matching what the fetch standard parses and what reqwest already uses</td>
</tr>
<tr>
<td>Request in</td>
<td><code>Request</code></td>
<td><code>fetch</code> and <code>Request::new</code> take an <code>impl TryInto&lt;Url&gt;</code>, a
<code>Request</code>, or an <code>http::Request&lt;B&gt;</code></td>
</tr>
<tr>
<td>Response out</td>
<td><code>Response</code></td>
<td>Converts into <code>http::Response&lt;Body&gt;</code>, and the body implements
<code>http_body::Body</code> so it feeds tower and hyper directly</td>
</tr>
<tr>
<td>Conversion errors</td>
<td><code>Error::InvalidHeader</code> and friends</td>
<td>Held until the builder resolves: at <code>build()</code> for a request, at the await for a fetch.
Either way the error names the offending header</td>
</tr>
</tbody>
</table>

<h2 class="section">Considered and dropped</h2>
<div class="dropped">
<ul>
<li><strong>A process-wide default agent</strong> with <code>set</code> and <code>close</code> accessors,
so a bare <code>fetch(url)</code> free function could exist. Dropped because a hidden global owning a
connection pool and a disk cache is worth more scrutiny in Rust than in Node. The cost is that two
libraries in one process cannot share a pool without passing an agent between them.</li>
<li><strong>A reqwest-shaped <code>Client</code></strong> with <code>get</code>, <code>post</code>, and
friends. Dropped in favour of one verb, which keeps the fetch vocabulary the specs are written in.</li>
<li><strong>Crate namespaces</strong> (<code>web-faith::cookies</code>). Not available: RFC 3243 is
accepted but unimplemented, with the cargo, crates.io, and docs.rs issues all open.</li>
</ul>
</div>

</div>
</body>
</html>
Loading
Loading