lumen_script/http.rs
1//! The HTTP seam behind the scripts' `fetch(url, tag)` and `http(#{...})`
2//! builtins.
3//!
4//! [`HttpClient`] is the transport contract: one blocking call that turns an
5//! [`HttpRequest`] into an [`HttpResponse`], with a byte cap on the body it
6//! buffers. The client Lumen ships is `lumen-http-ureq`, selected by the
7//! default-on `http-fetch` feature. An embedder that needs a different stack (a
8//! proxy-aware client, a recording client in tests, an in-process fake)
9//! implements the trait and installs it with
10//! [`FetchRegistry::with_client`](crate::runtime::FetchRegistry::with_client)
11//! before the script plugin builds.
12//!
13//! [`HttpDispatch`] is the concurrency contract next to it: who runs the
14//! request and how its reply gets back. [`ThreadDispatch`] is the one every
15//! desktop build uses, a worker thread per request blocked in
16//! [`HttpClient::send`]. A platform with no threads to block supplies its own
17//! and installs it with
18//! [`FetchRegistry::with_dispatch`](crate::runtime::FetchRegistry::with_dispatch);
19//! in a browser that is `lumen-web-http`, which runs the request on the page's
20//! own `fetch` and needs no client behind it.
21//!
22//! [`HttpHook`] is how a plugin takes part in every request without replacing
23//! either: it adjusts a request just before it is dispatched and reads each
24//! reply before the script does. The hooks live in
25//! [`HttpHooks`](crate::runtime::HttpHooks), apart from the registry, so they
26//! survive a client being swapped.
27//!
28//! The types are transport-only on purpose: the delivery tag, the
29//! `fetch()`-versus-`http()` reply style, and the retry or logging policy all
30//! stay on the runtime side of the seam, so a client implementation never has
31//! to know what a script is.
32
33use std::fmt;
34use std::str::FromStr;
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38use thiserror::Error;
39
40/// A request handed to a client on a per-request worker thread. Method, url,
41/// headers, and body in: the input half of the Qt `QNetworkRequest` shape.
42#[derive(Clone, Debug, Default)]
43pub struct HttpRequest {
44 /// HTTP method. Case-insensitive; clients uppercase it.
45 pub method: String,
46 /// Absolute request url, as the script wrote it.
47 pub url: String,
48 /// Request headers, in the order the script supplied them.
49 pub headers: Vec<(String, String)>,
50 /// Request body, sent verbatim. `None` sends no body at all.
51 pub body: Option<String>,
52 /// Whole-request deadline. `None` leaves the client's own default in
53 /// place.
54 pub timeout_ms: Option<u64>,
55 /// Whether the request carries the page's cookies and HTTP
56 /// authentication. A browser transport acts on it. A desktop client has
57 /// no page and ignores it; an [`HttpHook`] that keeps credentials of its
58 /// own, such as a cookie jar, reads it.
59 pub credentials: Credentials,
60}
61
62/// Which requests carry credentials (cookies, HTTP authentication, client
63/// certificates), with the meaning web `fetch` gives its `credentials`
64/// option.
65///
66/// A browser transport passes this to `fetch`. The desktop client has no page
67/// and ignores it, and an [`HttpHook`] that keeps credentials of its own reads
68/// it: `Omit` keeps a request out of it. The default is the browser's own.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub enum Credentials {
71 /// Never send credentials, and ignore any a reply sets.
72 Omit,
73 /// Send credentials to the page's own origin only.
74 #[default]
75 SameOrigin,
76 /// Send credentials to every origin, cross-origin included. The server
77 /// has to allow it with `Access-Control-Allow-Credentials`.
78 Include,
79}
80
81impl Credentials {
82 /// The spelling a script writes, which is web `fetch`'s.
83 pub const fn as_str(self) -> &'static str {
84 match self {
85 Credentials::Omit => "omit",
86 Credentials::SameOrigin => "same-origin",
87 Credentials::Include => "include",
88 }
89 }
90}
91
92impl fmt::Display for Credentials {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.write_str(self.as_str())
95 }
96}
97
98/// A `credentials` value that is none of the three web `fetch` names.
99#[derive(Clone, Debug, PartialEq, Eq, Error)]
100#[error("credentials must be \"include\", \"same-origin\" or \"omit\", not {0:?}")]
101pub struct UnknownCredentials(pub String);
102
103impl FromStr for Credentials {
104 type Err = UnknownCredentials;
105
106 /// Read a script's spelling. Exact and case-sensitive, like `fetch`:
107 /// a misspelled value is an error rather than a silent default, since
108 /// the default sends no cookies cross-origin and the failure would show
109 /// up only as a server refusing the request.
110 fn from_str(s: &str) -> Result<Self, Self::Err> {
111 match s {
112 "omit" => Ok(Credentials::Omit),
113 "same-origin" => Ok(Credentials::SameOrigin),
114 "include" => Ok(Credentials::Include),
115 other => Err(UnknownCredentials(other.to_string())),
116 }
117 }
118}
119
120/// The reply half of the Qt `QNetworkReply` shape: status, headers, and body
121/// out.
122///
123/// A 4xx or 5xx belongs here, not in the error channel: it is a completed
124/// reply, and the runtime decides what a non-2xx means for each builtin.
125#[derive(Clone, Debug, Default)]
126pub struct HttpResponse {
127 /// Numeric status line code.
128 pub status: u16,
129 /// Response headers as received. The runtime lowercases names before
130 /// handing them to a script.
131 pub headers: Vec<(String, String)>,
132 /// Response body, decoded as UTF-8 with invalid bytes replaced.
133 pub body: String,
134}
135
136/// The transport a script's HTTP builtins run on.
137///
138/// Implementations are called from a per-request worker thread and may block
139/// for the length of the request, so they must not touch the world.
140pub trait HttpClient: Send + Sync + 'static {
141 /// Perform `request` and return the reply.
142 ///
143 /// `Err` is reserved for transport failures (DNS, connect, TLS, timeout,
144 /// a malformed method or url); any status that came back over the wire is
145 /// `Ok`. The error string reaches the script verbatim as the reply's
146 /// `error` field, so it should read as a sentence.
147 ///
148 /// `body_limit` is a hard cap, in bytes, on the response body buffered
149 /// into memory. Reading past it must fail rather than allocate: an
150 /// open-ended or streaming endpoint must not be able to exhaust memory on
151 /// the worker thread.
152 fn send(&self, request: &HttpRequest, body_limit: u64) -> Result<HttpResponse, String>;
153}
154
155/// Something that takes part in every HTTP exchange a script starts, whichever
156/// client and dispatcher carry it.
157///
158/// A plugin registers one on [`HttpHooks`](crate::runtime::HttpHooks) to add
159/// what a request should carry and to read what a reply says, without the
160/// runtime knowing what it is for: a cookie jar reads `Set-Cookie` and sends
161/// `Cookie`, and a signing hook adds an authorization header. Hooks run in the
162/// order they were registered.
163pub trait HttpHook: Send + Sync + 'static {
164 /// Called on the world thread just before `request` is dispatched. The
165 /// request goes out as this leaves it.
166 fn before_send(&self, request: &mut HttpRequest) {
167 let _ = request;
168 }
169
170 /// Called with the reply to `request`, on whichever thread completed it,
171 /// before the script sees it. Not called for a transport failure.
172 fn on_reply(&self, request: &HttpRequest, response: &HttpResponse) {
173 let _ = (request, response);
174 }
175}
176
177/// The completion callback a dispatcher owns for the life of one request. It
178/// is called exactly once, from whatever thread or task the reply arrived on,
179/// and it never touches the world: all it does is hand the outcome to the
180/// channel the world thread drains.
181pub type HttpDone = Box<dyn FnOnce(Result<HttpResponse, String>) + Send + 'static>;
182
183/// How a request leaves the calling thread and how its reply comes back.
184///
185/// [`HttpClient`] says how to perform a request; this says who performs it and
186/// when the answer arrives. They are separate because the answer is not always
187/// a blocked thread waiting on a socket: a browser has no thread to block and
188/// resolves the request from a promise instead.
189pub trait HttpDispatch: Send + Sync + 'static {
190 /// Start `request` and hand its outcome to `done` exactly once.
191 ///
192 /// This call returns immediately; it must never block on the request. The
193 /// dispatcher forwards `body_limit` to the client unchanged - the cap is
194 /// the caller's policy, not the transport's. `label` names the work for
195 /// diagnostics (it becomes the worker thread's name) and carries no
196 /// meaning a dispatcher may act on.
197 fn dispatch(&self, label: &str, request: HttpRequest, body_limit: u64, done: HttpDone);
198}
199
200/// The dispatcher every desktop build runs: one worker thread per request,
201/// blocked in [`HttpClient::send`] until the reply lands.
202///
203/// A thread per request suits the usual "a few API calls per UI action"
204/// workload; a pool can replace it behind the same trait if that stops being
205/// true.
206///
207/// A platform with no threads cannot run this one, and up front is the only
208/// place to say so: [`std::thread::Builder::spawn`] consumes the completion
209/// callback, so a failed spawn leaves nothing to report the failure through.
210/// Such a platform gets an error naming what it should install instead, which
211/// is what a browser build sees until `lumen-web-http` goes in.
212pub struct ThreadDispatch(
213 // Unreachable where there are no threads, since `dispatch` answers before
214 // it would reach the client. It stays in place so both platforms build the
215 // same type rather than a second one appearing for the smaller case.
216 #[cfg_attr(target_arch = "wasm32", allow(dead_code))] Arc<dyn HttpClient>,
217);
218
219impl ThreadDispatch {
220 /// Run every dispatched request on `client`.
221 pub fn new(client: Arc<dyn HttpClient>) -> Self {
222 Self(client)
223 }
224}
225
226impl HttpDispatch for ThreadDispatch {
227 #[cfg(not(target_arch = "wasm32"))]
228 fn dispatch(&self, label: &str, request: HttpRequest, body_limit: u64, done: HttpDone) {
229 let client = Arc::clone(&self.0);
230 std::thread::Builder::new()
231 .name(format!("lumen-http:{label}"))
232 .spawn(move || done(client.send(&request, body_limit)))
233 .expect("spawn http thread");
234 }
235
236 #[cfg(target_arch = "wasm32")]
237 fn dispatch(&self, _label: &str, request: HttpRequest, _body_limit: u64, done: HttpDone) {
238 done(Err(format!(
239 "cannot request {}: this platform has no thread to run a request \
240 on, so a build for it installs its own dispatcher with \
241 FetchRegistry::with_dispatch",
242 request.url
243 )));
244 }
245}
246
247/// The client a build without the `http-fetch` feature gets: every request
248/// resolves to an error carrying the rebuild hint, so a size-trimmed binary
249/// says why `fetch()` did nothing instead of silently dropping it.
250#[derive(Clone, Copy, Debug, Default)]
251pub struct DisabledHttpClient;
252
253impl HttpClient for DisabledHttpClient {
254 fn send(&self, _request: &HttpRequest, _body_limit: u64) -> Result<HttpResponse, String> {
255 Err(
256 "the script runtime was built without the `http-fetch` feature; \
257 fetch() / http() are disabled in this binary"
258 .to_string(),
259 )
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::{Credentials, UnknownCredentials};
266
267 #[test]
268 fn credentials_read_back_as_the_spelling_they_print_as() {
269 for credentials in [
270 Credentials::Omit,
271 Credentials::SameOrigin,
272 Credentials::Include,
273 ] {
274 let spelled = credentials.to_string();
275 assert_eq!(spelled, credentials.as_str());
276 assert_eq!(spelled.parse::<Credentials>(), Ok(credentials));
277 }
278 assert_eq!(Credentials::default().as_str(), "same-origin");
279 }
280
281 #[test]
282 fn a_misspelled_credentials_value_is_refused_by_name() {
283 let refused = "Include".parse::<Credentials>().unwrap_err();
284 assert_eq!(refused, UnknownCredentials("Include".to_string()));
285 assert!(refused.to_string().contains("\"Include\""), "{refused}");
286 }
287}