trillium-frontend
A Trillium 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 installfrom crates.io - Dev-proxy mode (
dev-proxyfeature + 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
Usage
use Client;
use frontend;
use ClientConfig;
The same code works in all three modes — .with_client() is accepted but ignored when the dev-proxy feature is not enabled.
Mode selection
The mode is determined by two factors:
- Whether the
dev-proxycargo feature is enabled - Whether
package.jsonexists 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 |
Enabling dev-proxy mode
Pass the feature flag when you want live-reloading during development:
# Development: live-reloading proxy
# Production: build and embed assets at compile time
You can also define a feature in your own crate that forwards it, for brevity:
[]
= ["trillium-frontend/dev-proxy"]
Macro syntax
// Simple: path is relative to the Cargo.toml of the calling crate
frontend!
// With explicit overrides
frontend!
| 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 |
The full dev command is assembled as {run_prefix} {framework_command} --port {port}. The PORT environment variable is also set, so you can reference it in a custom dev_command.
Builder API
FrontendHandler is returned by the frontend! macro and offers these builder methods:
| Method | Description |
|---|---|
.with_client(Client) |
Required in dev-proxy mode. Provide your runtime's HTTP connector for the proxy. Accepted but ignored in other modes. |
.with_index_file("index.html") |
Enable SPA fallback: serve this file for any unmatched path. |
.with_dev_command("npm run dev -- --port $PORT") |
Override the auto-detected dev command. When set, you are responsible for port handling — $PORT is exported as an env var. Only used in dev-proxy mode. |
.with_dev_port(3000) |
Pin the dev server to a specific port instead of picking a free one automatically. Only used in dev-proxy mode. |
Publishing for cargo install
To support cargo install without requiring JS tooling on the user's machine, include the dist directory in your published crate but exclude package.json:
[]
= [
"src/",
"client/dist/", # pre-built assets to embed
# Do NOT include client/package.json — its absence triggers prebuilt mode
]
The absence of package.json tells trillium-frontend to use the pre-built assets as-is without attempting to run a build command.
Then publish with:
How it works
Dev-proxy mode (dev-proxy feature + source)
- The proc macro detects the framework and package manager at compile time and records the dev command in the
FrontendHandler. - When Trillium calls
Handler::init,FrontendHandler:- Picks a free port (or uses
.with_dev_port) - Spawns
sh -c "<dev_command> --port <port>"in the project directory - Polls the port until the dev server responds (up to ~1 second with 10 ms retries)
- Builds a
trillium-proxypointing athttp://localhost:<port>with WebSocket upgrade support
- Picks a free port (or uses
- All subsequent requests (HTTP and WS) are forwarded to the dev server.
- On
Drop, the dev process is killed.
Build mode (no dev-proxy feature + source)
- At compile time, the proc macro runs the build command (
sh -c "<build_command>") in the project directory. - The compiled
dist/directory is embedded usingtrillium-static-compiled::static_compiled!. - If
dist/index.htmlexists, it is also embedded as a separate SPA fallback handler. - No external process is spawned at runtime.
Prebuilt mode (no source)
- At compile time, the proc macro finds no
package.jsonand skips the build step entirely. - The existing
dist/directory is embedded usingtrillium-static-compiled::static_compiled!. - Identical runtime behavior to build mode.
Safety
This crate uses #![forbid(unsafe_code)] (via its dependencies; the crate itself contains no unsafe code).