feat: add conditional asset embedding with dev/prod build separation

- Add embed-assets feature flag to make rust-embed/mime_guess optional
- Update Justfile with backend-dev command for development (no embedded assets)
- Add CORS middleware when assets not embedded (for Vite proxy)
- Improve db recipe with Bun script supporting start/reset/rm commands
This commit is contained in:
2026-01-28 13:31:00 -06:00
parent 966732a6d2
commit 57a6a9871f
5 changed files with 126 additions and 69 deletions
+6 -2
View File
@@ -4,6 +4,10 @@ version = "0.3.4"
edition = "2024" edition = "2024"
default-run = "banner" default-run = "banner"
[features]
default = ["embed-assets"]
embed-assets = ["dep:rust-embed", "dep:mime_guess"]
[dependencies] [dependencies]
anyhow = "1.0.99" anyhow = "1.0.99"
async-trait = "0.1" async-trait = "0.1"
@@ -46,8 +50,8 @@ once_cell = "1.21.3"
serde_path_to_error = "0.1.17" serde_path_to_error = "0.1.17"
num-format = "0.4.4" num-format = "0.4.4"
tower-http = { version = "0.6.0", features = ["fs", "cors", "trace", "timeout"] } tower-http = { version = "0.6.0", features = ["fs", "cors", "trace", "timeout"] }
rust-embed = { version = "8.0", features = ["debug-embed", "include-exclude"] } rust-embed = { version = "8.0", features = ["include-exclude"], optional = true }
mime_guess = "2.0" mime_guess = { version = "2.0", optional = true }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
rapidhash = "4.1.0" rapidhash = "4.1.0"
yansi = "1.0.1" yansi = "1.0.1"
+69 -29
View File
@@ -22,36 +22,72 @@ format-check:
bun run --cwd web format:check bun run --cwd web format:check
# Start PostgreSQL in Docker and update .env with connection string # Start PostgreSQL in Docker and update .env with connection string
db: # Commands: start (default), reset, rm
#!/usr/bin/env bash [script("bun")]
set -euo pipefail db cmd="start":
const fs = await import("fs/promises");
const { spawnSync } = await import("child_process");
# Find available port const NAME = "banner-postgres";
PORT=$(shuf -i 49152-65535 -n 1) const USER = "banner";
while ss -tlnp 2>/dev/null | grep -q ":$PORT "; do const PASS = "banner";
PORT=$(shuf -i 49152-65535 -n 1) const DB = "banner";
done const PORT = "59489";
const ENV_FILE = ".env";
const CMD = "{{cmd}}";
# Start PostgreSQL container const run = (args) => spawnSync("docker", args, { encoding: "utf8" });
docker run -d \ const getContainer = () => {
--name banner-postgres \ const res = run(["ps", "-a", "--filter", `name=^${NAME}$`, "--format", "json"]);
-e POSTGRES_PASSWORD=banner \ return res.stdout.trim() ? JSON.parse(res.stdout) : null;
-e POSTGRES_USER=banner \ };
-e POSTGRES_DB=banner \
-p "$PORT:5432" \
postgres:17-alpine
# Update .env file const updateEnv = async () => {
DB_URL="postgresql://banner:banner@localhost:$PORT/banner" const url = `postgresql://${USER}:${PASS}@localhost:${PORT}/${DB}`;
if [ -f .env ]; then try {
sed -i.bak "s|^DATABASE_URL=.*|DATABASE_URL=$DB_URL|" .env let content = await fs.readFile(ENV_FILE, "utf8");
else content = content.includes("DATABASE_URL=")
echo "DATABASE_URL=$DB_URL" > .env ? content.replace(/DATABASE_URL=.*$/m, `DATABASE_URL=${url}`)
fi : content.trim() + `\nDATABASE_URL=${url}\n`;
await fs.writeFile(ENV_FILE, content);
} catch {
await fs.writeFile(ENV_FILE, `DATABASE_URL=${url}\n`);
}
};
echo "PostgreSQL started on port $PORT" const create = () => {
echo "DATABASE_URL=$DB_URL" run(["run", "-d", "--name", NAME, "-e", `POSTGRES_USER=${USER}`,
echo "Run: sqlx migrate run" "-e", `POSTGRES_PASSWORD=${PASS}`, "-e", `POSTGRES_DB=${DB}`,
"-p", `${PORT}:5432`, "postgres:17-alpine"]);
console.log("created");
};
const container = getContainer();
if (CMD === "rm") {
if (!container) process.exit(0);
run(["stop", NAME]);
run(["rm", NAME]);
console.log("removed");
} else if (CMD === "reset") {
if (!container) create();
else {
run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `DROP DATABASE IF EXISTS ${DB}`]);
run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `CREATE DATABASE ${DB}`]);
console.log("reset");
}
await updateEnv();
} else {
if (!container) {
create();
} else if (container.State !== "running") {
run(["start", NAME]);
console.log("started");
} else {
console.log("running");
}
await updateEnv();
}
# Auto-reloading frontend server # Auto-reloading frontend server
frontend: frontend:
@@ -61,10 +97,14 @@ frontend:
build-frontend: build-frontend:
bun run --cwd web build bun run --cwd web build
# Auto-reloading backend server # Auto-reloading backend server (with embedded assets)
backend *ARGS: backend *ARGS:
bacon --headless run -- -- {{ARGS}} bacon --headless run -- -- {{ARGS}}
# Auto-reloading backend server (no embedded assets, for dev proxy mode)
backend-dev *ARGS:
bacon --headless run -- --no-default-features -- {{ARGS}}
# Production build # Production build
build: build:
bun run --cwd web build bun run --cwd web build
@@ -74,6 +114,6 @@ build:
dev-build *ARGS='--services web --tracing pretty': build-frontend dev-build *ARGS='--services web --tracing pretty': build-frontend
bacon --headless run -- --profile dev-release -- {{ARGS}} bacon --headless run -- --profile dev-release -- {{ARGS}}
# Auto-reloading development build for both frontend and backend # Auto-reloading development build: Vite frontend + backend (no embedded assets, proxies to Vite)
[parallel] [parallel]
dev *ARGS='--services web,bot': frontend (backend ARGS) dev *ARGS='--services web,bot': frontend (backend-dev ARGS)
+1
View File
@@ -1,5 +1,6 @@
//! Web API module for the banner application. //! Web API module for the banner application.
#[cfg(feature = "embed-assets")]
pub mod assets; pub mod assets;
pub mod routes; pub mod routes;
+24 -12
View File
@@ -4,25 +4,29 @@ use axum::{
Router, Router,
body::Body, body::Body,
extract::{Request, State}, extract::{Request, State},
http::{HeaderMap, HeaderValue, StatusCode, Uri}, response::{Json, Response},
response::{Html, IntoResponse, Json, Response},
routing::get, routing::get,
}; };
#[cfg(feature = "embed-assets")]
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, Uri},
response::{Html, IntoResponse},
};
#[cfg(feature = "embed-assets")]
use http::header; use http::header;
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{collections::BTreeMap, time::Duration}; use std::{collections::BTreeMap, time::Duration};
use tower_http::timeout::TimeoutLayer; #[cfg(not(feature = "embed-assets"))]
use tower_http::{ use tower_http::cors::{Any, CorsLayer};
classify::ServerErrorsFailureClass, use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer};
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use tracing::{Span, debug, info, warn}; use tracing::{Span, debug, info, warn};
#[cfg(feature = "embed-assets")]
use crate::web::assets::{WebAssets, get_asset_metadata_cached}; use crate::web::assets::{WebAssets, get_asset_metadata_cached};
/// Set appropriate caching headers based on asset type /// Set appropriate caching headers based on asset type
#[cfg(feature = "embed-assets")]
fn set_caching_headers(response: &mut Response, path: &str, etag: &str) { fn set_caching_headers(response: &mut Response, path: &str, etag: &str) {
let headers = response.headers_mut(); let headers = response.headers_mut();
@@ -72,15 +76,21 @@ pub fn create_router(state: BannerState) -> Router {
let mut router = Router::new().nest("/api", api_router); let mut router = Router::new().nest("/api", api_router);
if cfg!(debug_assertions) { // When embed-assets feature is enabled, serve embedded static assets
#[cfg(feature = "embed-assets")]
{
router = router.fallback(fallback);
}
// Without embed-assets, enable CORS for dev proxy to Vite
#[cfg(not(feature = "embed-assets"))]
{
router = router.layer( router = router.layer(
CorsLayer::new() CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
.allow_methods(Any) .allow_methods(Any)
.allow_headers(Any), .allow_headers(Any),
) );
} else {
router = router.fallback(fallback);
} }
router.layer(( router.layer((
@@ -131,6 +141,7 @@ pub fn create_router(state: BannerState) -> Router {
} }
/// Handler that extracts request information for caching /// Handler that extracts request information for caching
#[cfg(feature = "embed-assets")]
async fn fallback(request: Request) -> Response { async fn fallback(request: Request) -> Response {
let uri = request.uri().clone(); let uri = request.uri().clone();
let headers = request.headers().clone(); let headers = request.headers().clone();
@@ -139,6 +150,7 @@ async fn fallback(request: Request) -> Response {
/// Handles SPA routing by serving index.html for non-API, non-asset requests /// Handles SPA routing by serving index.html for non-API, non-asset requests
/// This version includes HTTP caching headers and ETag support /// This version includes HTTP caching headers and ETag support
#[cfg(feature = "embed-assets")]
async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap) -> Response { async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap) -> Response {
let path = uri.path().trim_start_matches('/'); let path = uri.path().trim_start_matches('/');
+26 -26
View File
@@ -8,52 +8,52 @@
// You should NOT make any changes in this file as it will be overwritten. // You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root"; import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from "./routes/index"; import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: "/", id: '/',
path: "/", path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
"/": typeof IndexRoute; '/': typeof IndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
"/": typeof IndexRoute; '/': typeof IndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport; __root__: typeof rootRouteImport
"/": typeof IndexRoute; '/': typeof IndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath; fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: "/"; fullPaths: '/'
fileRoutesByTo: FileRoutesByTo; fileRoutesByTo: FileRoutesByTo
to: "/"; to: '/'
id: "__root__" | "/"; id: '__root__' | '/'
fileRoutesById: FileRoutesById; fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute; IndexRoute: typeof IndexRoute
} }
declare module "@tanstack/react-router" { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
"/": { '/': {
id: "/"; id: '/'
path: "/"; path: '/'
fullPath: "/"; fullPath: '/'
preLoaderRoute: typeof IndexRouteImport; preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
}; }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>(); ._addFileTypes<FileRouteTypes>()