Skip to main content

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}