Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
target/
desktop/
web/
.git/
.scratch/
*.md
Expand Down
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ RELAY_URL=ws://localhost:3000
# Set to true in production to require bearer token authentication
SPROUT_REQUIRE_AUTH_TOKEN=false

# Optional: path to the web UI dist directory. When set, the relay serves
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).
# SPROUT_WEB_DIR=./web/dist

# -----------------------------------------------------------------------------
# Auth
# -----------------------------------------------------------------------------
Expand Down
27 changes: 27 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ tokio-util = { version = "0.7", features = ["rt", "codec"] }
# HTTP + WebSocket
axum = { version = "0.8", features = ["ws", "macros"] }
tower = { version = "0.5", features = ["timeout", "util"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "fs"] }

# Database
sqlx = { version = "0.8", features = [
Expand Down
15 changes: 13 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
# ── Build stage ──────────────────────────────────────────────
# ── Build stage (Rust) ──────────────────────────────────────
# Hard-code --platform to prevent exec format error on ARM Macs.
FROM --platform=linux/amd64 rust:1.93-bookworm AS builder
WORKDIR /build
COPY . .
RUN cargo build --release -p sprout-relay \
&& strip target/release/sprout-relay

# ── Runtime stage ────────────────────────────────────────────
# ── Web build stage (Node/pnpm) ────────────────────────────
FROM --platform=linux/amd64 node:22-bookworm-slim AS web-builder
WORKDIR /build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY web/ web/
RUN corepack enable && pnpm install --frozen-lockfile --filter sprout-web
RUN pnpm -C web build

# ── Runtime stage ───────────────────────────────────────────
FROM --platform=linux/amd64 debian:bookworm-slim

# CAKE: non-root UID 1000 (numeric, not username)
Expand All @@ -20,9 +28,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates socat && rm -rf /var/lib/apt/lists/*

COPY --from=builder /build/target/release/sprout-relay /code/sprout-relay
COPY --from=web-builder /build/web/dist /code/web
COPY script/start /code/start
RUN chmod +x /code/start

ENV SPROUT_WEB_DIR="/code/web"

# CAKE: required Envoy env vars (overridden at runtime by CAKE).
ENV ENVOY_ADMIN_SOCKET_PATH="@envoy-admin.sock" \
ENVOY_INGRESS_PORT="20001" \
Expand Down
27 changes: 27 additions & 0 deletions crates/sprout-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ pub struct Config {
/// HMAC secret for git pre-receive hook callbacks.
/// Used to authenticate internal policy endpoint requests.
pub git_hook_hmac_secret: String,

// ── Web UI serving ────────────────────────────────────────────────────────
/// Optional path to the web UI `dist/` directory.
/// When set, the relay serves the SPA from this directory for browser requests.
/// When unset, no static file serving happens (relay behaves as before).
pub web_dir: Option<std::path::PathBuf>,
}

impl Config {
Expand Down Expand Up @@ -294,6 +300,26 @@ impl Config {
let secret: [u8; 32] = rand::random();
hex::encode(secret)
});
// Web UI static file serving
let web_dir = std::env::var("SPROUT_WEB_DIR")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);

if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
return Err(ConfigError::InvalidValue(format!(
"SPROUT_WEB_DIR={} does not contain index.html",
dir.display()
)));
}
tracing::info!(
"SPROUT_WEB_DIR={} — serving web UI from relay",
dir.display()
);
}

// Reject explicitly-configured secrets that are too short.
// The auto-generated fallback is always 64 hex chars (32 bytes), so this
// only fires when someone sets SPROUT_GIT_HOOK_HMAC_SECRET to a weak value.
Expand Down Expand Up @@ -332,6 +358,7 @@ impl Config {
git_max_repos_per_pubkey,
git_max_concurrent_ops,
git_hook_hmac_secret,
web_dir,
})
}
}
Expand Down
54 changes: 52 additions & 2 deletions crates/sprout-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use axum::{
use serde_json::json;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;

use crate::api;
Expand Down Expand Up @@ -76,10 +77,49 @@ pub fn build_router(state: Arc<AppState>) -> Router {

// Merge — each sub-router carries its own body limit.
// Metrics → Trace → CORS applied once over the combined router.
api_router
let mut merged = api_router
.merge(media_router)
.merge(git_router)
.merge(git_policy_router)
.merge(git_policy_router);

// When SPROUT_WEB_DIR is set, serve the SPA as a fallback for unmatched routes.
if let Some(ref web_dir) = state.config.web_dir {
let index_path = web_dir.join("index.html");
let spa_fallback = ServeDir::new(web_dir).not_found_service(tower::service_fn(
move |req: axum::extract::Request| {
let index = index_path.clone();
async move {
let path = req.uri().path();
// Reserved API prefixes must 404 normally, not serve index.html.
let reserved = path.starts_with("/api/")
|| path.starts_with("/media/")
|| path.starts_with("/git/")
|| path.starts_with("/internal/")
|| path.starts_with("/.well-known/")
|| path.starts_with("/huddle/")
|| path == "/health"
|| path == "/_liveness"
|| path == "/_readiness"
|| path == "/_status"
|| path == "/info";
// Files with extensions (e.g. /assets/missing.js) should 404.
let has_ext = path.rsplit('/').next().is_some_and(|seg| seg.contains('.'));
if reserved || has_ext {
Ok(StatusCode::NOT_FOUND.into_response())
} else {
// SPA client-side route → serve index.html
match tokio::fs::read(&index).await {
Ok(body) => Ok(axum::response::Html(body).into_response()),
Err(_) => Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
}
}
}
},
));
merged = merged.fallback_service(spa_fallback);
}

merged
.layer(middleware::from_fn(track_metrics))
.layer(TraceLayer::new_for_http())
.layer(build_cors_layer(&state.config.cors_origins))
Expand Down Expand Up @@ -129,6 +169,16 @@ async fn nip11_or_ws_handler(
.on_upgrade(move |socket| handle_connection(socket, state, addr))
.into_response(),
Err(_) => {
// Browser requesting HTML and web UI is configured → serve SPA.
if let Some(ref dir) = state.config.web_dir {
if accept.contains("text/html") {
let index = dir.join("index.html");
if let Ok(body) = tokio::fs::read(&index).await {
return axum::response::Html(body).into_response();
}
}
}
// Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
Json(info).into_response()
}
Expand Down
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ test-integration:
relay:
cargo run -p sprout-relay

# Start the relay with the built web UI served from it
relay-web:
#!/usr/bin/env bash
set -euo pipefail
[[ -d node_modules ]] || pnpm install
pnpm -C web build
SPROUT_WEB_DIR=./web/dist cargo run -p sprout-relay

# Start the relay server in release mode
relay-release:
cargo run -p sprout-relay --release
Expand Down
34 changes: 2 additions & 32 deletions web/src/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card";
import { ReposPage } from "@/features/repos/ui/ReposPage";

export const Route = createFileRoute("/")({
component: HomeRoute,
component: ReposPage,
});

function HomeRoute() {
const relayUrl = import.meta.env.VITE_RELAY_URL || "ws://localhost:3000";

return (
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Relay</CardTitle>
<CardDescription>Connected relay endpoint</CardDescription>
</CardHeader>
<CardContent>
<code
className="text-sm font-mono text-muted-foreground"
data-testid="relay-url"
>
{relayUrl}
</code>
</CardContent>
</Card>
</div>
);
}
5 changes: 2 additions & 3 deletions web/src/app/routes/repos.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { ReposPage } from "@/features/repos/ui/ReposPage";
import { Navigate, createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/repos")({
component: ReposPage,
component: () => <Navigate to="/" />,
});
26 changes: 6 additions & 20 deletions web/src/app/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,16 @@ export const Route = createRootRoute({
component: RootLayout,
});

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
className="text-sm text-muted-foreground transition-colors hover:text-foreground [&.active]:font-medium [&.active]:text-foreground"
>
{children}
</Link>
);
}

function RootLayout() {
return (
<div className="flex min-h-dvh flex-col">
<header className="flex h-12 items-center justify-between border-b px-4">
<nav className="flex items-center gap-4">
<Link
to="/"
className="text-sm font-semibold tracking-tight text-foreground"
>
Sprout
</Link>
<NavLink to="/repos">Repos</NavLink>
</nav>
<Link
to="/"
className="text-sm font-semibold tracking-tight text-foreground"
>
Sprout
</Link>
<ThemeToggle />
</header>
<main className="flex flex-1 flex-col">
Expand Down
17 changes: 17 additions & 0 deletions web/src/features/repos/ui/ConnectButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ExternalLink } from "lucide-react";

import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";

export function ConnectButton({ className }: { className?: string }) {
const deepLink = `sprout://connect?relay=${encodeURIComponent(relayWsUrl())}`;

return (
<Button asChild className={className}>
<a href={deepLink}>
<ExternalLink className="h-4 w-4" />
Connect to Relay
</a>
</Button>
);
}
Loading