diff --git a/Cargo.toml b/Cargo.toml index fe4db01..ac251bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.3.4" edition = "2024" default-run = "banner" +[features] +default = ["embed-assets"] +embed-assets = ["dep:rust-embed", "dep:mime_guess"] + [dependencies] anyhow = "1.0.99" async-trait = "0.1" @@ -46,8 +50,8 @@ once_cell = "1.21.3" serde_path_to_error = "0.1.17" num-format = "0.4.4" tower-http = { version = "0.6.0", features = ["fs", "cors", "trace", "timeout"] } -rust-embed = { version = "8.0", features = ["debug-embed", "include-exclude"] } -mime_guess = "2.0" +rust-embed = { version = "8.0", features = ["include-exclude"], optional = true } +mime_guess = { version = "2.0", optional = true } clap = { version = "4.5", features = ["derive"] } rapidhash = "4.1.0" yansi = "1.0.1" diff --git a/Justfile b/Justfile index 1c308a7..5f00100 100644 --- a/Justfile +++ b/Justfile @@ -22,36 +22,72 @@ format-check: bun run --cwd web format:check # Start PostgreSQL in Docker and update .env with connection string -db: - #!/usr/bin/env bash - set -euo pipefail +# Commands: start (default), reset, rm +[script("bun")] +db cmd="start": + const fs = await import("fs/promises"); + const { spawnSync } = await import("child_process"); - # Find available port - PORT=$(shuf -i 49152-65535 -n 1) - while ss -tlnp 2>/dev/null | grep -q ":$PORT "; do - PORT=$(shuf -i 49152-65535 -n 1) - done + const NAME = "banner-postgres"; + const USER = "banner"; + const PASS = "banner"; + const DB = "banner"; + const PORT = "59489"; + const ENV_FILE = ".env"; + const CMD = "{{cmd}}"; - # Start PostgreSQL container - docker run -d \ - --name banner-postgres \ - -e POSTGRES_PASSWORD=banner \ - -e POSTGRES_USER=banner \ - -e POSTGRES_DB=banner \ - -p "$PORT:5432" \ - postgres:17-alpine + const run = (args) => spawnSync("docker", args, { encoding: "utf8" }); + const getContainer = () => { + const res = run(["ps", "-a", "--filter", `name=^${NAME}$`, "--format", "json"]); + return res.stdout.trim() ? JSON.parse(res.stdout) : null; + }; - # Update .env file - DB_URL="postgresql://banner:banner@localhost:$PORT/banner" - if [ -f .env ]; then - sed -i.bak "s|^DATABASE_URL=.*|DATABASE_URL=$DB_URL|" .env - else - echo "DATABASE_URL=$DB_URL" > .env - fi + const updateEnv = async () => { + const url = `postgresql://${USER}:${PASS}@localhost:${PORT}/${DB}`; + try { + let content = await fs.readFile(ENV_FILE, "utf8"); + content = content.includes("DATABASE_URL=") + ? content.replace(/DATABASE_URL=.*$/m, `DATABASE_URL=${url}`) + : 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" - echo "DATABASE_URL=$DB_URL" - echo "Run: sqlx migrate run" + const create = () => { + run(["run", "-d", "--name", NAME, "-e", `POSTGRES_USER=${USER}`, + "-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 frontend: @@ -61,10 +97,14 @@ frontend: build-frontend: bun run --cwd web build -# Auto-reloading backend server +# Auto-reloading backend server (with embedded assets) backend *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 build: bun run --cwd web build @@ -74,6 +114,6 @@ build: dev-build *ARGS='--services web --tracing pretty': build-frontend 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] -dev *ARGS='--services web,bot': frontend (backend ARGS) +dev *ARGS='--services web,bot': frontend (backend-dev ARGS) diff --git a/src/web/mod.rs b/src/web/mod.rs index 3ab95f0..0e5a030 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,5 +1,6 @@ //! Web API module for the banner application. +#[cfg(feature = "embed-assets")] pub mod assets; pub mod routes; diff --git a/src/web/routes.rs b/src/web/routes.rs index 75d9e19..5a0eeb9 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -4,25 +4,29 @@ use axum::{ Router, body::Body, extract::{Request, State}, - http::{HeaderMap, HeaderValue, StatusCode, Uri}, - response::{Html, IntoResponse, Json, Response}, + response::{Json, Response}, routing::get, }; +#[cfg(feature = "embed-assets")] +use axum::{ + http::{HeaderMap, HeaderValue, StatusCode, Uri}, + response::{Html, IntoResponse}, +}; +#[cfg(feature = "embed-assets")] use http::header; use serde::Serialize; use serde_json::{Value, json}; use std::{collections::BTreeMap, time::Duration}; -use tower_http::timeout::TimeoutLayer; -use tower_http::{ - classify::ServerErrorsFailureClass, - cors::{Any, CorsLayer}, - trace::TraceLayer, -}; +#[cfg(not(feature = "embed-assets"))] +use tower_http::cors::{Any, CorsLayer}; +use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer}; use tracing::{Span, debug, info, warn}; +#[cfg(feature = "embed-assets")] use crate::web::assets::{WebAssets, get_asset_metadata_cached}; /// Set appropriate caching headers based on asset type +#[cfg(feature = "embed-assets")] fn set_caching_headers(response: &mut Response, path: &str, etag: &str) { 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); - 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( CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any), - ) - } else { - router = router.fallback(fallback); + ); } router.layer(( @@ -131,6 +141,7 @@ pub fn create_router(state: BannerState) -> Router { } /// Handler that extracts request information for caching +#[cfg(feature = "embed-assets")] async fn fallback(request: Request) -> Response { let uri = request.uri().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 /// 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 { let path = uri.path().trim_start_matches('/'); diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 1980223..d204c26 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -8,52 +8,52 @@ // 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. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as IndexRouteImport } from "./routes/index"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; + '/': typeof IndexRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; + '/': typeof IndexRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/"; - fileRoutesByTo: FileRoutesByTo; - to: "/"; - id: "__root__" | "/"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; + IndexRoute: typeof IndexRoute } -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, -}; +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes()