trillium-frontend 0.4.3

Trillium handler for JS/TS frontend projects: proxies a dev server in debug mode, embeds compiled assets in release mode
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! A [Trillium](https://trillium.rs) handler for serving JS frontend projects with
//! three operating modes:
//!
//! - **Build mode** (default, source available): runs the frontend build at **compile time** and
//!   embeds all dist assets directly in the binary via [`trillium_static_compiled`]
//! - **Prebuilt mode** (default, no source): embeds pre-built dist assets without attempting to
//!   build — this is what happens during `cargo install` from crates.io when the published crate
//!   includes only the dist directory
//! - **Dev-proxy mode** (`dev-proxy` feature + source available): auto-detects your framework and
//!   package manager, spawns the dev server on a free port, and proxies all requests to it —
//!   including WebSocket upgrades for HMR
//!
//! # Mode selection
//!
//! The mode is determined by two factors:
//!
//! 1. Whether the `dev-proxy` cargo feature is enabled
//! 2. Whether `package.json` exists in the frontend project directory (i.e., source is available)
//!
//! | `dev-proxy` feature | `package.json` exists | Mode |
//! |--------------------|-----------------------|------|
//! | enabled | yes | Dev-proxy |
//! | enabled | no | Prebuilt (fallback) |
//! | disabled | yes | Build |
//! | disabled | no | Prebuilt |
//!
//! # Usage
//!
//! ```rust,ignore
//! use trillium_client::Client;
//! use trillium_frontend::frontend;
//! use trillium_smol::ClientConfig;
//!
//! fn main() {
//!     trillium_smol::run((
//!         frontend!("./client")
//!             .with_client(Client::new(ClientConfig::default()))
//!             .with_index_file("index.html"),
//!     ));
//! }
//! ```
//!
//! The same code works in all three modes — `.with_client()` is accepted but ignored
//! when the `dev-proxy` feature is not enabled.
//!
//! # Enabling dev-proxy mode
//!
//! Pass the feature flag when you want live-reloading during development:
//!
//! ```sh
//! # Development: live-reloading proxy
//! cargo run --features trillium-frontend/dev-proxy
//!
//! # Production: build and embed assets at compile time
//! cargo build --release
//! ```
//!
//! You can also define a feature in your own crate that forwards it, for brevity:
//!
//! ```toml
//! [features]
//! dev-proxy = ["trillium-frontend/dev-proxy"]
//! ```
//!
//! ```sh
//! cargo run --features dev-proxy
//! ```
//!
//! # Macro syntax
//!
//! ```rust,ignore
//! // Simple: path is relative to the Cargo.toml of the calling crate
//! frontend!("./client")
//!
//! // With explicit overrides
//! frontend!(
//!     path = "./client",
//!     build = "bun run build",
//!     dist = "dist",
//! )
//! ```
//!
//! | Argument | Description |
//! |----------|-------------|
//! | `path`   | Path to the frontend project directory (required) |
//! | `build`  | Build command override (default: auto-detected from framework) |
//! | `dist`   | Dist directory name relative to `path` (default: auto-detected, usually `"dist"`) |
//!
//! # Auto-detection
//!
//! When source is available (`package.json` exists), `trillium-frontend` inspects the project
//! directory for known config and lock files to determine the build and dev commands.
//!
//! **Package manager** (detected by lock file, in priority order):
//!
//! | Lock file | Package manager | Run prefix |
//! |-----------|----------------|------------|
//! | `bun.lockb` / `bun.lock` | Bun | `bun run` |
//! | `pnpm-lock.yaml` | pnpm | `pnpm exec` |
//! | `yarn.lock` | Yarn | `yarn exec` |
//! | `package-lock.json` | npm | `npx` |
//!
//! **Framework** (detected by config file):
//!
//! | Config file | Framework | Dev command | Build command | Dist dir |
//! |-------------|-----------|-------------|---------------|----------|
//! | `vite.config.{js,ts,mjs}` | Vite | `vite --strictPort --clearScreen false` | `vite build` | `dist` |
//! | `webpack.config.{js,ts,mjs}` | Webpack | `webpack serve` | `webpack build` | `dist` |
//! | `next.config.{js,ts,mjs}` | Next.js | `next dev` | `next build` | `.next` |
//!
//! # Rebuilding when frontend sources change
//!
//! In build mode the frontend build runs at compile time, so cargo must treat your JS/TS
//! sources as compile inputs — otherwise editing *only* a frontend file and re-running
//! `cargo build` can leave the previously embedded assets in place. On **nightly** this is
//! handled automatically (the macro registers source files via the unstable
//! `proc_macro_tracked_path` feature, detected at build time — nothing embedded, no setup).
//! On **stable**, proc macros can't declare file dependencies, so a frontend-only edit may
//! be missed until another recompile is triggered. If you develop or build on stable, or
//! want belt-and-suspenders insurance on any toolchain, add the optional [`build`]-script
//! shim — it is not essential. Dev-proxy mode needs none of this (HMR handles it).
//!
//! # Publishing for `cargo install`
//!
//! To support `cargo install` without requiring JS tooling on the user's machine:
//!
//! 1. Build your frontend assets (e.g., `cd client && npm run build`)
//! 2. Include the dist directory in your published crate, but **exclude** `package.json`
//!
//! The absence of `package.json` tells `trillium-frontend` to use the pre-built assets
//! as-is without attempting to run a build command.
//!
//! ```toml
//! [package]
//! include = [
//!     "src/",
//!     "client/dist/",   # pre-built assets to embed
//!     # Do NOT include client/package.json — its absence triggers prebuilt mode
//! ]
//! ```
//!
//! Then publish with:
//!
//! ```sh
//! cargo build --release  # triggers the frontend build via the proc macro
//! cargo publish
//! ```

#![forbid(unsafe_code)]

pub mod build;

use fieldwork::Fieldwork;
use std::borrow::Cow;
#[cfg(feature = "dev-proxy")]
use std::{
    io::ErrorKind,
    process::{Child, Command},
    sync::Mutex,
    time::Duration,
};
#[cfg(feature = "dev-proxy")]
use trillium::Upgrade;
use trillium::{Conn, Handler, Info, Method};
use trillium_client::Client;
#[cfg(feature = "dev-proxy")]
use trillium_proxy::{Connector, Proxy, Url};
use trillium_static_compiled::StaticCompiledHandler;

#[derive(Fieldwork)]
#[fieldwork(opt_in, with, into, option_set_some)]
pub struct FrontendHandler {
    assets: Option<StaticCompiledHandler>,

    /// A `StaticCompiledHandler` pointing at the dist index file, used for SPA
    /// fallback (serves index for any path not matched by `assets`).
    spa_fallback: Option<StaticCompiledHandler>,

    #[allow(dead_code)]
    project_path: Cow<'static, str>,

    #[allow(dead_code)]
    detected_dev_command: Option<Cow<'static, str>>,

    /// trillium HTTP client for dev-mode proxying (ignored without `dev-proxy` feature)
    #[allow(dead_code)]
    #[field]
    client: Option<Client>,

    /// override auto-detected dev command (ignored without `dev-proxy` feature)
    #[allow(dead_code)]
    #[field]
    dev_command: Option<Cow<'static, str>>,

    /// override auto-detected dev port (ignored without `dev-proxy` feature)
    #[allow(dead_code)]
    #[field(into = false)]
    dev_port: Option<u16>,

    /// opt-in SPA index file fallback (e.g. `"index.html"`)
    #[field]
    index_file: Option<&'static str>,

    /// opt-in predicate gating the SPA fallback: the index is served only for
    /// requests it accepts (ignored without a `spa_fallback`)
    #[field(into = false)]
    index_predicate: Option<fn(&Conn) -> bool>,

    #[cfg(feature = "dev-proxy")]
    dev_process: Option<Mutex<Child>>,

    #[cfg(feature = "dev-proxy")]
    proxy: Option<Proxy<Url>>,
}

impl FrontendHandler {
    #[doc(hidden)]
    pub fn new(
        assets: Option<StaticCompiledHandler>,
        spa_fallback: Option<StaticCompiledHandler>,
        project_path: &'static str,
        detected_dev_command: Option<&'static str>,
    ) -> Self {
        FrontendHandler {
            assets,
            spa_fallback,
            project_path: Cow::Borrowed(project_path),
            detected_dev_command: detected_dev_command.map(Cow::Borrowed),
            client: None,
            dev_command: None,
            dev_port: None,
            index_file: None,
            index_predicate: None,
            #[cfg(feature = "dev-proxy")]
            dev_process: None,
            #[cfg(feature = "dev-proxy")]
            proxy: None,
        }
    }
}

impl FrontendHandler {
    /// Whether the SPA index should stand in for a request no asset matched.
    ///
    /// Only a navigation can be answered with the index, so non-`GET`/`HEAD`
    /// requests are excluded regardless of any predicate: a `POST` to an
    /// arbitrary path is not a client-side route, and answering it `200` with
    /// the index misrepresents it as one. Beyond that, an
    /// [`index_predicate`](Self::with_index_predicate) may narrow the fallback
    /// to the paths the app actually routes, so that everything else 404s.
    fn serves_index(&self, conn: &Conn) -> bool {
        matches!(conn.method(), Method::Get | Method::Head)
            && self.index_predicate.is_none_or(|predicate| predicate(conn))
    }
}

impl Handler for FrontendHandler {
    async fn run(&self, conn: Conn) -> Conn {
        if let Some(assets) = self.assets {
            let conn = assets.run(conn).await;
            if !conn.is_halted()
                && let Some(spa) = self.spa_fallback.as_ref()
                && self.serves_index(&conn)
            {
                return spa.run(conn).await;
            }
            return conn;
        }

        #[cfg(feature = "dev-proxy")]
        if let Some(proxy) = &self.proxy {
            return proxy.run(conn).await;
        }

        conn
    }

    async fn init(&mut self, #[allow(unused)] info: &mut Info) {
        if self.assets.is_some()
            && let Some(index) = self.index_file
        {
            self.assets = self.assets.map(|h| h.with_index_file(index));
        }

        #[cfg(feature = "dev-proxy")]
        {
            let client = self.client.take().expect(
                "trillium-frontend: in dev-proxy mode, provide a Client via .with_client()",
            );

            let port = self.dev_port.unwrap_or_else(|| {
                portpicker::pick_unused_port()
                    .expect("trillium-frontend: could not find a free port for the dev server")
            });

            let dev_command = if let Some(cmd) = self.dev_command.as_deref() {
                cmd.to_string()
            } else {
                let detected = self.detected_dev_command.as_deref().expect(
                    "trillium-frontend: no dev command detected; configure with \
                     .with_dev_command()",
                );
                format!("{detected} --port {port}")
            };

            let child = Command::new("sh")
                .arg("-c")
                .arg(&dev_command)
                .env("PORT", port.to_string())
                .current_dir(self.project_path.as_ref())
                .spawn()
                .expect("trillium-frontend: failed to spawn dev server");
            self.dev_process = Some(Mutex::new(child));

            let upstream = format!("http://localhost:{port}").parse().unwrap();
            wait_for_port(&upstream, &client).await;

            let mut proxy = Proxy::new(client, upstream).with_websocket_upgrades();
            proxy.init(info).await;
            self.proxy = Some(proxy);
        }
    }

    #[cfg(feature = "dev-proxy")]
    fn has_upgrade(&self, upgrade: &Upgrade) -> bool {
        self.proxy.as_ref().is_some_and(|p| p.has_upgrade(upgrade))
    }

    #[cfg(feature = "dev-proxy")]
    async fn upgrade(&self, upgrade: Upgrade) {
        if let Some(proxy) = &self.proxy {
            proxy.upgrade(upgrade).await;
        }
    }
}

#[cfg(feature = "dev-proxy")]
impl Drop for FrontendHandler {
    fn drop(&mut self) {
        if let Some(mutex) = self.dev_process.take()
            && let Ok(mut child) = mutex.into_inner()
        {
            let _ = child.kill();
        }
    }
}

#[cfg(feature = "dev-proxy")]
async fn wait_for_port(upstream: &Url, client: &Client) {
    for _ in 0..100 {
        log::trace!("testing connection");
        match client.connector().connect(upstream).await {
            Ok(_) => {
                log::trace!("tcp connected");
                return;
            }

            Err(e) if e.kind() == ErrorKind::ConnectionRefused => {
                log::trace!("Could not connect to {upstream} yet, sleeping 10ms");
                client
                    .connector()
                    .runtime()
                    .delay(Duration::from_millis(10))
                    .await;
            }

            Err(other) => {
                panic!("trillium-frontend was unable to connect to the dev server: {other}");
            }
        }
    }
}

#[doc(hidden)]
pub mod __macro_internals {
    pub use crate::FrontendHandler;
    pub use trillium_frontend_macros::frontend_impl;
    pub use trillium_static_compiled::static_compiled;
}

/// Build a [`FrontendHandler`] for your frontend project.
///
/// The mode is determined by the `dev-proxy` cargo feature and whether source
/// files (`package.json`) exist at the project path:
///
/// - **Dev-proxy** (`dev-proxy` feature + source): spawns dev server, proxies requests
/// - **Build** (no feature + source): builds frontend at compile time, embeds assets
/// - **Prebuilt** (no source): embeds pre-built dist assets as-is
///
/// # Usage
///
/// ```rust,ignore
/// // Simple: just the project path
/// trillium_frontend::frontend!("./client")
///     .with_client(Client::new(SmolConnector::default()))
///     .with_index_file("index.html")
///
/// // With explicit overrides
/// trillium_frontend::frontend!(
///     path = "./client",
///     build = "vite build",
///     dist = "dist",
/// )
/// ```
#[macro_export]
macro_rules! frontend {
    ($($tt:tt)*) => {{
        use $crate::__macro_internals::{FrontendHandler, static_compiled};
        $crate::__macro_internals::frontend_impl!($($tt)*)
    }};
}