Skip to main content

trillium_frontend/
lib.rs

1//! A [Trillium](https://trillium.rs) handler for serving JS frontend projects with
2//! three operating modes:
3//!
4//! - **Build mode** (default, source available): runs the frontend build at **compile time** and
5//!   embeds all dist assets directly in the binary via [`trillium_static_compiled`]
6//! - **Prebuilt mode** (default, no source): embeds pre-built dist assets without attempting to
7//!   build — this is what happens during `cargo install` from crates.io when the published crate
8//!   includes only the dist directory
9//! - **Dev-proxy mode** (`dev-proxy` feature + source available): auto-detects your framework and
10//!   package manager, spawns the dev server on a free port, and proxies all requests to it —
11//!   including WebSocket upgrades for HMR
12//!
13//! # Mode selection
14//!
15//! The mode is determined by two factors:
16//!
17//! 1. Whether the `dev-proxy` cargo feature is enabled
18//! 2. Whether `package.json` exists in the frontend project directory (i.e., source is available)
19//!
20//! | `dev-proxy` feature | `package.json` exists | Mode |
21//! |--------------------|-----------------------|------|
22//! | enabled | yes | Dev-proxy |
23//! | enabled | no | Prebuilt (fallback) |
24//! | disabled | yes | Build |
25//! | disabled | no | Prebuilt |
26//!
27//! # Usage
28//!
29//! ```rust,ignore
30//! use trillium_client::Client;
31//! use trillium_frontend::frontend;
32//! use trillium_smol::ClientConfig;
33//!
34//! fn main() {
35//!     trillium_smol::run((
36//!         frontend!("./client")
37//!             .with_client(Client::new(ClientConfig::default()))
38//!             .with_index_file("index.html"),
39//!     ));
40//! }
41//! ```
42//!
43//! The same code works in all three modes — `.with_client()` is accepted but ignored
44//! when the `dev-proxy` feature is not enabled.
45//!
46//! # Enabling dev-proxy mode
47//!
48//! Pass the feature flag when you want live-reloading during development:
49//!
50//! ```sh
51//! # Development: live-reloading proxy
52//! cargo run --features trillium-frontend/dev-proxy
53//!
54//! # Production: build and embed assets at compile time
55//! cargo build --release
56//! ```
57//!
58//! You can also define a feature in your own crate that forwards it, for brevity:
59//!
60//! ```toml
61//! [features]
62//! dev-proxy = ["trillium-frontend/dev-proxy"]
63//! ```
64//!
65//! ```sh
66//! cargo run --features dev-proxy
67//! ```
68//!
69//! # Macro syntax
70//!
71//! ```rust,ignore
72//! // Simple: path is relative to the Cargo.toml of the calling crate
73//! frontend!("./client")
74//!
75//! // With explicit overrides
76//! frontend!(
77//!     path = "./client",
78//!     build = "bun run build",
79//!     dist = "dist",
80//! )
81//! ```
82//!
83//! | Argument | Description |
84//! |----------|-------------|
85//! | `path`   | Path to the frontend project directory (required) |
86//! | `build`  | Build command override (default: auto-detected from framework) |
87//! | `dist`   | Dist directory name relative to `path` (default: auto-detected, usually `"dist"`) |
88//!
89//! # Auto-detection
90//!
91//! When source is available (`package.json` exists), `trillium-frontend` inspects the project
92//! directory for known config and lock files to determine the build and dev commands.
93//!
94//! **Package manager** (detected by lock file, in priority order):
95//!
96//! | Lock file | Package manager | Run prefix |
97//! |-----------|----------------|------------|
98//! | `bun.lockb` / `bun.lock` | Bun | `bun run` |
99//! | `pnpm-lock.yaml` | pnpm | `pnpm exec` |
100//! | `yarn.lock` | Yarn | `yarn exec` |
101//! | `package-lock.json` | npm | `npx` |
102//!
103//! **Framework** (detected by config file):
104//!
105//! | Config file | Framework | Dev command | Build command | Dist dir |
106//! |-------------|-----------|-------------|---------------|----------|
107//! | `vite.config.{js,ts,mjs}` | Vite | `vite --strictPort --clearScreen false` | `vite build` | `dist` |
108//! | `webpack.config.{js,ts,mjs}` | Webpack | `webpack serve` | `webpack build` | `dist` |
109//! | `next.config.{js,ts,mjs}` | Next.js | `next dev` | `next build` | `.next` |
110//!
111//! # Rebuilding when frontend sources change
112//!
113//! In build mode the frontend build runs at compile time, so cargo must treat your JS/TS
114//! sources as compile inputs — otherwise editing *only* a frontend file and re-running
115//! `cargo build` can leave the previously embedded assets in place. On **nightly** this is
116//! handled automatically (the macro registers source files via the unstable
117//! `proc_macro_tracked_path` feature, detected at build time — nothing embedded, no setup).
118//! On **stable**, proc macros can't declare file dependencies, so a frontend-only edit may
119//! be missed until another recompile is triggered. If you develop or build on stable, or
120//! want belt-and-suspenders insurance on any toolchain, add the optional [`build`]-script
121//! shim — it is not essential. Dev-proxy mode needs none of this (HMR handles it).
122//!
123//! # Publishing for `cargo install`
124//!
125//! To support `cargo install` without requiring JS tooling on the user's machine:
126//!
127//! 1. Build your frontend assets (e.g., `cd client && npm run build`)
128//! 2. Include the dist directory in your published crate, but **exclude** `package.json`
129//!
130//! The absence of `package.json` tells `trillium-frontend` to use the pre-built assets
131//! as-is without attempting to run a build command.
132//!
133//! ```toml
134//! [package]
135//! include = [
136//!     "src/",
137//!     "client/dist/",   # pre-built assets to embed
138//!     # Do NOT include client/package.json — its absence triggers prebuilt mode
139//! ]
140//! ```
141//!
142//! Then publish with:
143//!
144//! ```sh
145//! cargo build --release  # triggers the frontend build via the proc macro
146//! cargo publish
147//! ```
148
149#![forbid(unsafe_code)]
150
151pub mod build;
152
153use fieldwork::Fieldwork;
154use std::borrow::Cow;
155#[cfg(feature = "dev-proxy")]
156use std::{
157    io::ErrorKind,
158    process::{Child, Command},
159    sync::Mutex,
160    time::Duration,
161};
162#[cfg(feature = "dev-proxy")]
163use trillium::Upgrade;
164use trillium::{Conn, Handler, Info, Method};
165use trillium_client::Client;
166#[cfg(feature = "dev-proxy")]
167use trillium_proxy::{Connector, Proxy, Url};
168use trillium_static_compiled::StaticCompiledHandler;
169
170#[derive(Fieldwork)]
171#[fieldwork(opt_in, with, into, option_set_some)]
172pub struct FrontendHandler {
173    assets: Option<StaticCompiledHandler>,
174
175    /// A `StaticCompiledHandler` pointing at the dist index file, used for SPA
176    /// fallback (serves index for any path not matched by `assets`).
177    spa_fallback: Option<StaticCompiledHandler>,
178
179    #[allow(dead_code)]
180    project_path: Cow<'static, str>,
181
182    #[allow(dead_code)]
183    detected_dev_command: Option<Cow<'static, str>>,
184
185    /// trillium HTTP client for dev-mode proxying (ignored without `dev-proxy` feature)
186    #[allow(dead_code)]
187    #[field]
188    client: Option<Client>,
189
190    /// override auto-detected dev command (ignored without `dev-proxy` feature)
191    #[allow(dead_code)]
192    #[field]
193    dev_command: Option<Cow<'static, str>>,
194
195    /// override auto-detected dev port (ignored without `dev-proxy` feature)
196    #[allow(dead_code)]
197    #[field(into = false)]
198    dev_port: Option<u16>,
199
200    /// opt-in SPA index file fallback (e.g. `"index.html"`)
201    #[field]
202    index_file: Option<&'static str>,
203
204    /// opt-in predicate gating the SPA fallback: the index is served only for
205    /// requests it accepts (ignored without a `spa_fallback`)
206    #[field(into = false)]
207    index_predicate: Option<fn(&Conn) -> bool>,
208
209    #[cfg(feature = "dev-proxy")]
210    dev_process: Option<Mutex<Child>>,
211
212    #[cfg(feature = "dev-proxy")]
213    proxy: Option<Proxy<Url>>,
214}
215
216impl FrontendHandler {
217    #[doc(hidden)]
218    pub fn new(
219        assets: Option<StaticCompiledHandler>,
220        spa_fallback: Option<StaticCompiledHandler>,
221        project_path: &'static str,
222        detected_dev_command: Option<&'static str>,
223    ) -> Self {
224        FrontendHandler {
225            assets,
226            spa_fallback,
227            project_path: Cow::Borrowed(project_path),
228            detected_dev_command: detected_dev_command.map(Cow::Borrowed),
229            client: None,
230            dev_command: None,
231            dev_port: None,
232            index_file: None,
233            index_predicate: None,
234            #[cfg(feature = "dev-proxy")]
235            dev_process: None,
236            #[cfg(feature = "dev-proxy")]
237            proxy: None,
238        }
239    }
240}
241
242impl FrontendHandler {
243    /// Whether the SPA index should stand in for a request no asset matched.
244    ///
245    /// Only a navigation can be answered with the index, so non-`GET`/`HEAD`
246    /// requests are excluded regardless of any predicate: a `POST` to an
247    /// arbitrary path is not a client-side route, and answering it `200` with
248    /// the index misrepresents it as one. Beyond that, an
249    /// [`index_predicate`](Self::with_index_predicate) may narrow the fallback
250    /// to the paths the app actually routes, so that everything else 404s.
251    fn serves_index(&self, conn: &Conn) -> bool {
252        matches!(conn.method(), Method::Get | Method::Head)
253            && self.index_predicate.is_none_or(|predicate| predicate(conn))
254    }
255}
256
257impl Handler for FrontendHandler {
258    async fn run(&self, conn: Conn) -> Conn {
259        if let Some(assets) = self.assets {
260            let conn = assets.run(conn).await;
261            if !conn.is_halted()
262                && let Some(spa) = self.spa_fallback.as_ref()
263                && self.serves_index(&conn)
264            {
265                return spa.run(conn).await;
266            }
267            return conn;
268        }
269
270        #[cfg(feature = "dev-proxy")]
271        if let Some(proxy) = &self.proxy {
272            return proxy.run(conn).await;
273        }
274
275        conn
276    }
277
278    async fn init(&mut self, #[allow(unused)] info: &mut Info) {
279        if self.assets.is_some()
280            && let Some(index) = self.index_file
281        {
282            self.assets = self.assets.map(|h| h.with_index_file(index));
283        }
284
285        #[cfg(feature = "dev-proxy")]
286        {
287            let client = self.client.take().expect(
288                "trillium-frontend: in dev-proxy mode, provide a Client via .with_client()",
289            );
290
291            let port = self.dev_port.unwrap_or_else(|| {
292                portpicker::pick_unused_port()
293                    .expect("trillium-frontend: could not find a free port for the dev server")
294            });
295
296            let dev_command = if let Some(cmd) = self.dev_command.as_deref() {
297                cmd.to_string()
298            } else {
299                let detected = self.detected_dev_command.as_deref().expect(
300                    "trillium-frontend: no dev command detected; configure with \
301                     .with_dev_command()",
302                );
303                format!("{detected} --port {port}")
304            };
305
306            let child = Command::new("sh")
307                .arg("-c")
308                .arg(&dev_command)
309                .env("PORT", port.to_string())
310                .current_dir(self.project_path.as_ref())
311                .spawn()
312                .expect("trillium-frontend: failed to spawn dev server");
313            self.dev_process = Some(Mutex::new(child));
314
315            let upstream = format!("http://localhost:{port}").parse().unwrap();
316            wait_for_port(&upstream, &client).await;
317
318            let mut proxy = Proxy::new(client, upstream).with_websocket_upgrades();
319            proxy.init(info).await;
320            self.proxy = Some(proxy);
321        }
322    }
323
324    #[cfg(feature = "dev-proxy")]
325    fn has_upgrade(&self, upgrade: &Upgrade) -> bool {
326        self.proxy.as_ref().is_some_and(|p| p.has_upgrade(upgrade))
327    }
328
329    #[cfg(feature = "dev-proxy")]
330    async fn upgrade(&self, upgrade: Upgrade) {
331        if let Some(proxy) = &self.proxy {
332            proxy.upgrade(upgrade).await;
333        }
334    }
335}
336
337#[cfg(feature = "dev-proxy")]
338impl Drop for FrontendHandler {
339    fn drop(&mut self) {
340        if let Some(mutex) = self.dev_process.take()
341            && let Ok(mut child) = mutex.into_inner()
342        {
343            let _ = child.kill();
344        }
345    }
346}
347
348#[cfg(feature = "dev-proxy")]
349async fn wait_for_port(upstream: &Url, client: &Client) {
350    for _ in 0..100 {
351        log::trace!("testing connection");
352        match client.connector().connect(upstream).await {
353            Ok(_) => {
354                log::trace!("tcp connected");
355                return;
356            }
357
358            Err(e) if e.kind() == ErrorKind::ConnectionRefused => {
359                log::trace!("Could not connect to {upstream} yet, sleeping 10ms");
360                client
361                    .connector()
362                    .runtime()
363                    .delay(Duration::from_millis(10))
364                    .await;
365            }
366
367            Err(other) => {
368                panic!("trillium-frontend was unable to connect to the dev server: {other}");
369            }
370        }
371    }
372}
373
374#[doc(hidden)]
375pub mod __macro_internals {
376    pub use crate::FrontendHandler;
377    pub use trillium_frontend_macros::frontend_impl;
378    pub use trillium_static_compiled::static_compiled;
379}
380
381/// Build a [`FrontendHandler`] for your frontend project.
382///
383/// The mode is determined by the `dev-proxy` cargo feature and whether source
384/// files (`package.json`) exist at the project path:
385///
386/// - **Dev-proxy** (`dev-proxy` feature + source): spawns dev server, proxies requests
387/// - **Build** (no feature + source): builds frontend at compile time, embeds assets
388/// - **Prebuilt** (no source): embeds pre-built dist assets as-is
389///
390/// # Usage
391///
392/// ```rust,ignore
393/// // Simple: just the project path
394/// trillium_frontend::frontend!("./client")
395///     .with_client(Client::new(SmolConnector::default()))
396///     .with_index_file("index.html")
397///
398/// // With explicit overrides
399/// trillium_frontend::frontend!(
400///     path = "./client",
401///     build = "vite build",
402///     dist = "dist",
403/// )
404/// ```
405#[macro_export]
406macro_rules! frontend {
407    ($($tt:tt)*) => {{
408        use $crate::__macro_internals::{FrontendHandler, static_compiled};
409        $crate::__macro_internals::frontend_impl!($($tt)*)
410    }};
411}