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}