feat: add Rust reverse proxy with JSON logging

- Axum-based API server with Unix socket and TCP support
- Custom tracing formatters for Railway-compatible JSON logs
- SvelteKit hooks and Vite plugin for unified logging
- Justfile updated for concurrent dev workflow with hl log viewer
This commit is contained in:
2026-01-04 18:21:00 -06:00
parent 07ea1c093e
commit d86027d27a
19 changed files with 3069 additions and 11 deletions
Vendored
+5
View File
@@ -3,3 +3,8 @@ target/
.vscode/
web/build/
web/.svelte-kit/
# Added by cargo
/target
+98
View File
@@ -0,0 +1,98 @@
#:schema https://raw.githubusercontent.com/pamburus/hl/v0.34.0/schema/json/config.schema.json
# Configuration for Railway-compatible JSON logs
# Usage: hl --config .hl.config.toml
# Time format for display (Railway ignores timestamp but we include it)
time-format = "%b %d %T.%3N"
time-zone = "UTC"
# Input file display settings
input-info = "auto"
# ASCII mode detection
ascii = "auto"
# Theme
theme = "uni"
theme-overlays = ["@accent-italic"]
[fields]
# Don't ignore any fields by default
ignore = []
hide = []
# Field configuration for Railway format
[fields.predefined]
# Timestamp field (Railway ignores this but we want consistency)
[fields.predefined.time]
show = "auto"
names = [
"timestamp",
"ts",
"time",
"@timestamp"
]
# Logger field (optional, matches Rust 'target' field)
[fields.predefined.logger]
names = ["logger", "target", "span.name"]
# Level field (Railway uses this)
[fields.predefined.level]
show = "auto"
[[fields.predefined.level.variants]]
names = ["level"]
[fields.predefined.level.variants.values]
error = ["error", "err", "fatal", "critical", "panic"]
warning = ["warning", "warn"]
info = ["info", "information"]
debug = ["debug"]
trace = ["trace"]
# Message field (Railway uses this)
[fields.predefined.message]
names = ["message", "msg"]
# Caller field
[fields.predefined.caller]
names = ["caller"]
[fields.predefined.caller-file]
names = []
[fields.predefined.caller-line]
names = []
# Formatting settings
[formatting]
# Flatten nested fields
flatten = "always"
# Message format - delimited for better readability
[formatting.message]
format = "delimited"
# Punctuation
[formatting.punctuation]
logger-name-separator = ":"
field-key-value-separator = "="
string-opening-quote = "'"
string-closing-quote = "'"
caller-name-file-separator = " @ "
hidden-fields-indicator = " ..."
level-left-separator = "["
level-right-separator = "]"
input-number-prefix = "#"
input-number-left-separator = ""
input-name-left-separator = ""
array-separator = " "
source-location-separator = { ascii = "-> ", unicode = "→ " }
input-number-right-separator = { ascii = " | ", unicode = " │ " }
input-name-right-separator = { ascii = " | ", unicode = " │ " }
input-name-clipping = { ascii = "..", unicode = "··" }
input-name-common-part = { ascii = "..", unicode = "··" }
message-delimiter = { ascii = "::", unicode = "" }
Generated
+1561
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
clap = { version = "4.5.54", features = ["derive", "env"] }
nu-ansi-term = "0.50.3"
reqwest = { version = "0.13", features = ["charset", "json"], default-features = false }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
time = { version = "0.3.44", features = ["formatting", "macros"] }
tokio = { version = "1.49.0", features = ["full"] }
tokio-util = { version = "0.7.18", features = ["io"] }
tower = "0.5"
tower-http = { version = "0.6.8", features = ["trace", "cors"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
ulid = { version = "1", features = ["serde"] }
+11 -1
View File
@@ -1,13 +1,23 @@
default:
just --list
dev:
bun run --cwd web dev
just dev-json | hl --config .hl.config.toml -P
dev-json:
LOG_JSON=true UPSTREAM_URL=/tmp/xevion-api.sock bunx concurrently --raw --prefix none "bun run --silent --cwd web dev --port 5173" "cargo watch --quiet --exec 'run --quiet -- --listen localhost:8080 --listen /tmp/xevion-api.sock --downstream http://localhost:5173'"
setup:
bun install --cwd web
cargo build
build:
bun run --cwd web build
cargo build --release
check:
bun run --cwd web format
bun run --cwd web lint
bun run --cwd web check
cargo clippy --all-targets
cargo fmt --check
+142
View File
@@ -0,0 +1,142 @@
use clap::Parser;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::str::FromStr;
/// Server configuration parsed from CLI arguments and environment variables
#[derive(Parser, Debug)]
#[command(name = "api")]
#[command(about = "xevion.dev API server with ISR caching", long_about = None)]
pub struct Args {
/// Address(es) to listen on. Can be host:port, :port, or Unix socket path.
/// Can be specified multiple times.
/// Examples: :8080, 0.0.0.0:8080, [::]:8080, /tmp/api.sock
#[arg(long, env = "LISTEN_ADDR", value_delimiter = ',', required = true)]
pub listen: Vec<ListenAddr>,
/// Downstream Bun SSR server URL or Unix socket path
/// Examples: http://localhost:5173, /tmp/bun.sock
#[arg(long, env = "DOWNSTREAM_URL", required = true)]
pub downstream: String,
/// Optional header name to trust for request IDs (e.g., X-Railway-Request-Id)
#[arg(long, env = "TRUST_REQUEST_ID")]
pub trust_request_id: Option<String>,
}
/// Address to listen on - either TCP or Unix socket
#[derive(Debug, Clone)]
pub enum ListenAddr {
Tcp(SocketAddr),
Unix(PathBuf),
}
impl FromStr for ListenAddr {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Unix socket: starts with / or ./
if s.starts_with('/') || s.starts_with("./") {
return Ok(ListenAddr::Unix(PathBuf::from(s)));
}
// Shorthand :port -> 127.0.0.1:port
if let Some(port_str) = s.strip_prefix(':') {
let port: u16 = port_str
.parse()
.map_err(|_| format!("Invalid port number: {}", port_str))?;
return Ok(ListenAddr::Tcp(SocketAddr::from(([127, 0, 0, 1], port))));
}
// Try parsing as a socket address (handles both IPv4 and IPv6)
// This supports formats like: 0.0.0.0:8080, [::]:8080, 192.168.1.1:3000
match s.parse::<SocketAddr>() {
Ok(addr) => Ok(ListenAddr::Tcp(addr)),
Err(_) => {
// Try resolving as hostname:port
match s.to_socket_addrs() {
Ok(mut addrs) => addrs
.next()
.ok_or_else(|| format!("Could not resolve address: {}", s))
.map(ListenAddr::Tcp),
Err(_) => Err(format!(
"Invalid address '{}'. Expected host:port, :port, or Unix socket path",
s
)),
}
}
}
}
}
impl std::fmt::Display for ListenAddr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ListenAddr::Tcp(addr) => write!(f, "{}", addr),
ListenAddr::Unix(path) => write!(f, "{}", path.display()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_shorthand_port() {
let addr: ListenAddr = ":8080".parse().unwrap();
match addr {
ListenAddr::Tcp(socket) => {
assert_eq!(socket.port(), 8080);
assert_eq!(socket.ip().to_string(), "127.0.0.1");
}
_ => panic!("Expected TCP address"),
}
}
#[test]
fn test_parse_ipv4() {
let addr: ListenAddr = "0.0.0.0:8080".parse().unwrap();
match addr {
ListenAddr::Tcp(socket) => {
assert_eq!(socket.port(), 8080);
assert_eq!(socket.ip().to_string(), "0.0.0.0");
}
_ => panic!("Expected TCP address"),
}
}
#[test]
fn test_parse_ipv6() {
let addr: ListenAddr = "[::]:8080".parse().unwrap();
match addr {
ListenAddr::Tcp(socket) => {
assert_eq!(socket.port(), 8080);
assert_eq!(socket.ip().to_string(), "::");
}
_ => panic!("Expected TCP address"),
}
}
#[test]
fn test_parse_unix_socket() {
let addr: ListenAddr = "/tmp/api.sock".parse().unwrap();
match addr {
ListenAddr::Unix(path) => {
assert_eq!(path, PathBuf::from("/tmp/api.sock"));
}
_ => panic!("Expected Unix socket"),
}
}
#[test]
fn test_parse_relative_unix_socket() {
let addr: ListenAddr = "./api.sock".parse().unwrap();
match addr {
ListenAddr::Unix(path) => {
assert_eq!(path, PathBuf::from("./api.sock"));
}
_ => panic!("Expected Unix socket"),
}
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Custom tracing formatter for Railway-compatible structured logging
use nu_ansi_term::Color;
use serde::Serialize;
use serde_json::{Map, Value};
use std::fmt;
use time::macros::format_description;
use time::{format_description::FormatItem, OffsetDateTime};
use tracing::field::{Field, Visit};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields, FormattedFields};
use tracing_subscriber::registry::LookupSpan;
/// Cached format description for timestamps with 3 subsecond digits (milliseconds)
const TIMESTAMP_FORMAT: &[FormatItem<'static>] =
format_description!("[hour]:[minute]:[second].[subsecond digits:3]");
/// A custom formatter with enhanced timestamp formatting and colored output
///
/// Provides human-readable output for local development with:
/// - Colored log levels
/// - Timestamp with millisecond precision
/// - Span context with hierarchy
/// - Clean field formatting
pub struct CustomPrettyFormatter;
impl<S, N> FormatEvent<S, N> for CustomPrettyFormatter
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let meta = event.metadata();
// 1) Timestamp (dimmed when ANSI)
let now = OffsetDateTime::now_utc();
let formatted_time = now.format(&TIMESTAMP_FORMAT).map_err(|e| {
eprintln!("Failed to format timestamp: {}", e);
fmt::Error
})?;
write_dimmed(&mut writer, formatted_time)?;
writer.write_char(' ')?;
// 2) Colored 5-char level
write_colored_level(&mut writer, meta.level())?;
writer.write_char(' ')?;
// 3) Span scope chain (bold names, fields in braces, dimmed ':')
if let Some(scope) = ctx.event_scope() {
let mut saw_any = false;
for span in scope.from_root() {
write_bold(&mut writer, span.metadata().name())?;
saw_any = true;
write_dimmed(&mut writer, ":")?;
let ext = span.extensions();
if let Some(fields) = &ext.get::<FormattedFields<N>>() {
if !fields.fields.is_empty() {
write_bold(&mut writer, "{")?;
writer.write_str(fields.fields.as_str())?;
write_bold(&mut writer, "}")?;
}
}
write_dimmed(&mut writer, ":")?;
}
if saw_any {
writer.write_char(' ')?;
}
}
// 4) Target (dimmed), then a space
if writer.has_ansi_escapes() {
write!(writer, "{}: ", Color::DarkGray.paint(meta.target()))?;
} else {
write!(writer, "{}: ", meta.target())?;
}
// 5) Event fields
ctx.format_fields(writer.by_ref(), event)?;
// 6) Newline
writeln!(writer)
}
}
/// A custom JSON formatter that flattens fields to root level for Railway
///
/// Outputs logs in Railway-compatible format:
/// ```json
/// {
/// "message": "...",
/// "level": "...",
/// "target": "...",
/// "customAttribute": "..."
/// }
/// ```
///
/// This format allows Railway to:
/// - Parse the `message` field correctly
/// - Filter by `level` and custom attributes using `@attribute:value`
/// - Preserve multi-line logs like stack traces
pub struct CustomJsonFormatter;
impl<S, N> FormatEvent<S, N> for CustomJsonFormatter
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let meta = event.metadata();
#[derive(Serialize)]
struct EventFields {
timestamp: String,
message: String,
level: String,
target: String,
#[serde(flatten)]
fields: Map<String, Value>,
}
let (message, fields) = {
let mut message: Option<String> = None;
let mut fields: Map<String, Value> = Map::new();
struct FieldVisitor<'a> {
message: &'a mut Option<String>,
fields: &'a mut Map<String, Value>,
}
impl<'a> Visit for FieldVisitor<'a> {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
let key = field.name();
if key == "message" {
*self.message = Some(format!("{:?}", value));
} else {
self.fields
.insert(key.to_string(), Value::String(format!("{:?}", value)));
}
}
fn record_str(&mut self, field: &Field, value: &str) {
let key = field.name();
if key == "message" {
*self.message = Some(value.to_string());
} else {
self.fields
.insert(key.to_string(), Value::String(value.to_string()));
}
}
fn record_i64(&mut self, field: &Field, value: i64) {
let key = field.name();
if key != "message" {
self.fields.insert(
key.to_string(),
Value::Number(serde_json::Number::from(value)),
);
}
}
fn record_u64(&mut self, field: &Field, value: u64) {
let key = field.name();
if key != "message" {
self.fields.insert(
key.to_string(),
Value::Number(serde_json::Number::from(value)),
);
}
}
fn record_bool(&mut self, field: &Field, value: bool) {
let key = field.name();
if key != "message" {
self.fields.insert(key.to_string(), Value::Bool(value));
}
}
}
let mut visitor = FieldVisitor {
message: &mut message,
fields: &mut fields,
};
event.record(&mut visitor);
// Collect span information from the span hierarchy
// Flatten all span fields directly into root level
if let Some(scope) = ctx.event_scope() {
for span in scope.from_root() {
// Extract span fields by parsing the stored extension data
// The fields are stored as a formatted string, so we need to parse them
let ext = span.extensions();
if let Some(formatted_fields) = ext.get::<FormattedFields<N>>() {
let field_str = formatted_fields.fields.as_str();
// Parse key=value pairs from the formatted string
// Format is typically: key=value key2=value2
for pair in field_str.split_whitespace() {
if let Some((key, value)) = pair.split_once('=') {
// Remove quotes if present
let value = value.trim_matches('"').trim_matches('\'');
fields.insert(key.to_string(), Value::String(value.to_string()));
}
}
}
}
}
(message, fields)
};
let json = EventFields {
timestamp: OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| String::from("1970-01-01T00:00:00Z")),
message: message.unwrap_or_default(),
level: meta.level().to_string().to_lowercase(),
target: meta.target().to_string(),
fields,
};
writeln!(
writer,
"{}",
serde_json::to_string(&json).unwrap_or_else(|_| "{}".to_string())
)
}
}
/// Write the verbosity level with colored output
fn write_colored_level(writer: &mut Writer<'_>, level: &Level) -> fmt::Result {
if writer.has_ansi_escapes() {
let colored = match *level {
Level::TRACE => Color::Purple.paint("TRACE"),
Level::DEBUG => Color::Blue.paint("DEBUG"),
Level::INFO => Color::Green.paint(" INFO"),
Level::WARN => Color::Yellow.paint(" WARN"),
Level::ERROR => Color::Red.paint("ERROR"),
};
write!(writer, "{}", colored)
} else {
// Right-pad to width 5 for alignment
match *level {
Level::TRACE => write!(writer, "{:>5}", "TRACE"),
Level::DEBUG => write!(writer, "{:>5}", "DEBUG"),
Level::INFO => write!(writer, "{:>5}", " INFO"),
Level::WARN => write!(writer, "{:>5}", " WARN"),
Level::ERROR => write!(writer, "{:>5}", "ERROR"),
}
}
}
fn write_dimmed(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result {
if writer.has_ansi_escapes() {
write!(writer, "{}", Color::DarkGray.paint(s.to_string()))
} else {
write!(writer, "{}", s)
}
}
fn write_bold(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result {
if writer.has_ansi_escapes() {
write!(writer, "{}", Color::White.bold().paint(s.to_string()))
} else {
write!(writer, "{}", s)
}
}
+418
View File
@@ -0,0 +1,418 @@
use axum::{
Json, Router,
extract::{Request, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::get,
};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
mod config;
mod formatter;
mod middleware;
use config::{Args, ListenAddr};
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
use middleware::RequestIdLayer;
fn init_tracing() {
let use_json = std::env::var("LOG_JSON")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
// Build the EnvFilter
// Priority: RUST_LOG > LOG_LEVEL > default
let filter = if let Ok(rust_log) = std::env::var("RUST_LOG") {
// RUST_LOG overwrites everything
EnvFilter::new(rust_log)
} else {
// Get LOG_LEVEL for our crate, default based on build profile
let our_level = std::env::var("LOG_LEVEL").unwrap_or_else(|_| {
if cfg!(debug_assertions) {
"debug".to_string()
} else {
"info".to_string()
}
});
// Default other crates to WARN, our crate to LOG_LEVEL
EnvFilter::new(format!("warn,api={}", our_level))
};
if use_json {
tracing_subscriber::registry()
.with(filter)
.with(
tracing_subscriber::fmt::layer()
.event_format(CustomJsonFormatter)
.fmt_fields(tracing_subscriber::fmt::format::DefaultFields::new())
.with_ansi(false), // Disable ANSI codes in JSON mode
)
.init();
} else {
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().event_format(CustomPrettyFormatter))
.init();
}
}
#[tokio::main]
async fn main() {
// Initialize tracing with configurable format and levels
init_tracing();
// Parse CLI arguments and environment variables
let args = Args::parse();
// Validate we have at least one listen address
if args.listen.is_empty() {
eprintln!("Error: At least one --listen address is required");
std::process::exit(1);
}
// Create shared application state
let state = Arc::new(AppState {
downstream_url: args.downstream.clone(),
});
// Build router with shared state
let app = Router::new()
.nest("/api", api_routes().fallback(api_404_handler))
.fallback(isr_handler)
.layer(TraceLayer::new_for_http())
.layer(RequestIdLayer::new(args.trust_request_id.clone()))
.layer(CorsLayer::permissive())
.with_state(state);
// Spawn a listener for each address
let mut tasks = Vec::new();
for listen_addr in &args.listen {
let app = app.clone();
let listen_addr = listen_addr.clone();
let task = tokio::spawn(async move {
match listen_addr {
ListenAddr::Tcp(addr) => {
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("Failed to bind TCP listener");
// Format as clickable URL
let url = if addr.is_ipv6() {
format!("http://[{}]:{}", addr.ip(), addr.port())
} else {
format!("http://{}:{}", addr.ip(), addr.port())
};
tracing::info!(url, "Listening on TCP");
axum::serve(listener, app)
.await
.expect("Server error on TCP listener");
}
ListenAddr::Unix(path) => {
// Remove existing socket file if it exists
let _ = std::fs::remove_file(&path);
let listener = tokio::net::UnixListener::bind(&path)
.expect("Failed to bind Unix socket listener");
tracing::info!(socket = %path.display(), "Listening on Unix socket");
axum::serve(listener, app)
.await
.expect("Server error on Unix socket listener");
}
}
});
tasks.push(task);
}
// Wait for all listeners (this will run forever unless interrupted)
for task in tasks {
task.await.expect("Listener task panicked");
}
}
/// Shared application state
#[derive(Clone)]
struct AppState {
downstream_url: String,
}
/// Custom error type for proxy operations
#[derive(Debug)]
enum ProxyError {
/// Network error (connection failed, timeout, etc.)
Network(reqwest::Error),
}
impl std::fmt::Display for ProxyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProxyError::Network(e) => write!(f, "Network error: {}", e),
}
}
}
impl std::error::Error for ProxyError {}
/// Check if a path represents a static asset that should be logged at TRACE level
fn is_static_asset(path: &str) -> bool {
path.starts_with("/node_modules/")
|| path.starts_with("/@") // Vite internals like /@vite/client, /@fs/, /@id/
|| path.starts_with("/.svelte-kit/")
|| path.starts_with("/.well-known/")
|| path.ends_with(".woff2")
|| path.ends_with(".woff")
|| path.ends_with(".ttf")
|| path.ends_with(".ico")
|| path.ends_with(".png")
|| path.ends_with(".jpg")
|| path.ends_with(".svg")
|| path.ends_with(".webp")
|| path.ends_with(".css")
|| path.ends_with(".js")
|| path.ends_with(".map")
}
/// Check if a path represents a page route (heuristic: no file extension)
fn is_page_route(path: &str) -> bool {
!path.starts_with("/node_modules/")
&& !path.starts_with("/@")
&& !path.starts_with("/.svelte-kit/")
&& !path.contains('.') // Simple heuristic: no extension = likely a page
}
// API routes for data endpoints
fn api_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/health", get(health_handler))
.route("/projects", get(projects_handler))
}
// Health check endpoint
async fn health_handler() -> impl IntoResponse {
(StatusCode::OK, "OK")
}
// API 404 fallback handler - catches unmatched /api/* routes
async fn api_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
tracing::warn!(path = %uri.path(), "API route not found");
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"path": uri.path()
})),
)
}
// Project data structure
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProjectLink {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Project {
id: String,
name: String,
#[serde(rename = "shortDescription")]
short_description: String,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
links: Vec<ProjectLink>,
}
// Projects endpoint - returns hardcoded project data for now
async fn projects_handler() -> impl IntoResponse {
let projects = vec![
Project {
id: "1".to_string(),
name: "xevion.dev".to_string(),
short_description: "Personal portfolio with fuzzy tag discovery".to_string(),
icon: None,
links: vec![ProjectLink {
url: "https://github.com/Xevion/xevion.dev".to_string(),
title: Some("GitHub".to_string()),
}],
},
Project {
id: "2".to_string(),
name: "Contest".to_string(),
short_description: "Competitive programming problem archive".to_string(),
icon: None,
links: vec![
ProjectLink {
url: "https://github.com/Xevion/contest".to_string(),
title: Some("GitHub".to_string()),
},
ProjectLink {
url: "https://contest.xevion.dev".to_string(),
title: Some("Demo".to_string()),
},
],
},
];
Json(projects)
}
// ISR handler - proxies to Bun SSR server
// This is the fallback for all routes not matched by /api/*
#[tracing::instrument(skip(state, req), fields(path = %req.uri().path()))]
async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Response {
let uri = req.uri();
let path = uri.path();
let query = uri.query().unwrap_or("");
// Check if API route somehow reached ISR handler (shouldn't happen)
if path.starts_with("/api/") {
tracing::error!("API request reached ISR handler - routing bug!");
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal routing error",
)
.into_response();
}
// Build URL for Bun server
let bun_url = if query.is_empty() {
format!("{}{}", state.downstream_url, path)
} else {
format!("{}{}?{}", state.downstream_url, path, query)
};
// Track request timing
let start = std::time::Instant::now();
// TODO: Add ISR caching layer here (moka, singleflight, stale-while-revalidate)
// For now, just proxy directly to Bun
match proxy_to_bun(&bun_url, &state.downstream_url).await {
Ok((status, headers, body)) => {
let duration_ms = start.elapsed().as_millis() as u64;
let cache = "miss"; // Hardcoded for now, will change when caching is implemented
// Intelligent logging based on path type and status
let is_static = is_static_asset(path);
let is_page = is_page_route(path);
match (status.as_u16(), is_static, is_page) {
// Static assets - success at TRACE
(200..=299, true, _) => {
tracing::trace!(status = status.as_u16(), duration_ms, cache, "ISR request");
}
// Static assets - 404 at WARN
(404, true, _) => {
tracing::warn!(
status = status.as_u16(),
duration_ms,
cache,
"ISR request - missing asset"
);
}
// Static assets - server error at ERROR
(500..=599, true, _) => {
tracing::error!(
status = status.as_u16(),
duration_ms,
cache,
"ISR request - server error"
);
}
// Page routes - success at DEBUG
(200..=299, _, true) => {
tracing::debug!(status = status.as_u16(), duration_ms, cache, "ISR request");
}
// Page routes - 404 silent (normal case for non-existent pages)
(404, _, true) => {}
// Page routes - server error at ERROR
(500..=599, _, _) => {
tracing::error!(
status = status.as_u16(),
duration_ms,
cache,
"ISR request - server error"
);
}
// Default fallback - DEBUG
_ => {
tracing::debug!(status = status.as_u16(), duration_ms, cache, "ISR request");
}
}
// Forward response
(status, headers, body).into_response()
}
Err(err) => {
let duration_ms = start.elapsed().as_millis() as u64;
tracing::error!(
error = %err,
url = %bun_url,
duration_ms,
"Failed to proxy to Bun"
);
(
StatusCode::BAD_GATEWAY,
format!("Failed to render page: {}", err),
)
.into_response()
}
}
}
// Proxy a request to the Bun SSR server, returning status, headers and body
async fn proxy_to_bun(
url: &str,
downstream_url: &str,
) -> Result<(StatusCode, HeaderMap, String), ProxyError> {
// Check if downstream is a Unix socket path
let client = if downstream_url.starts_with('/') || downstream_url.starts_with("./") {
// Unix socket
let path = PathBuf::from(downstream_url);
reqwest::Client::builder()
.unix_socket(path)
.build()
.map_err(ProxyError::Network)?
} else {
// Regular HTTP
reqwest::Client::new()
};
let response = client.get(url).send().await.map_err(ProxyError::Network)?;
// Extract status code
let status = StatusCode::from_u16(response.status().as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
// Convert reqwest headers to axum HeaderMap
let mut headers = HeaderMap::new();
for (name, value) in response.headers() {
// Skip hop-by-hop headers and content-length (axum will recalculate it)
let name_str = name.as_str();
if name_str == "transfer-encoding"
|| name_str == "connection"
|| name_str == "content-length"
{
continue;
}
if let Ok(header_name) = axum::http::HeaderName::try_from(name.as_str()) {
if let Ok(header_value) = axum::http::HeaderValue::try_from(value.as_bytes()) {
headers.insert(header_name, header_value);
}
}
}
let body = response.text().await.map_err(ProxyError::Network)?;
Ok((status, headers, body))
}
+85
View File
@@ -0,0 +1,85 @@
//! Request ID middleware for distributed tracing and correlation
use axum::{
body::Body,
extract::Request,
http::HeaderName,
response::Response,
};
use std::task::{Context, Poll};
use tower::{Layer, Service};
/// Layer that creates request ID spans for all requests
#[derive(Clone)]
pub struct RequestIdLayer {
/// Optional header name to trust for request IDs
trust_header: Option<HeaderName>,
}
impl RequestIdLayer {
/// Create a new request ID layer
pub fn new(trust_header: Option<String>) -> Self {
Self {
trust_header: trust_header.and_then(|h| h.parse().ok()),
}
}
}
impl<S> Layer<S> for RequestIdLayer {
type Service = RequestIdService<S>;
fn layer(&self, inner: S) -> Self::Service {
RequestIdService {
inner,
trust_header: self.trust_header.clone(),
}
}
}
/// Service that extracts or generates request IDs and creates tracing spans
#[derive(Clone)]
pub struct RequestIdService<S> {
inner: S,
trust_header: Option<HeaderName>,
}
impl<S> Service<Request> for RequestIdService<S>
where
S: Service<Request, Response = Response<Body>> + Send + 'static,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request) -> Self::Future {
// Extract or generate request ID
let req_id = self
.trust_header
.as_ref()
.and_then(|header| req.headers().get(header))
.and_then(|value| value.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| ulid::Ulid::new().to_string());
// Create a tracing span for this request
let span = tracing::info_span!("request", req_id = %req_id);
let _enter = span.enter();
// Clone span for the future
let span_clone = span.clone();
// Call the inner service
let future = self.inner.call(req);
Box::pin(async move {
// Execute the future within the span
let _enter = span_clone.enter();
future.await
})
}
}
+46 -1
View File
@@ -7,6 +7,7 @@
"@fontsource-variable/inter": "^5.1.0",
"@fontsource/hanken-grotesk": "^5.1.0",
"@fontsource/schibsted-grotesk": "^5.2.8",
"@logtape/logtape": "^1.3.5",
"bits-ui": "^2.8.2",
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.1",
@@ -17,6 +18,8 @@
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^25.0.3",
"concurrently": "^9.2.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
@@ -150,6 +153,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@logtape/logtape": ["@logtape/logtape@1.3.5", "", {}, "sha512-G+MxWB7Tbv/2764519+Cp6rKXUdRbe/GiRwTvlm/Wv/sNsiquRnx9Hzr9eXaIpAYLT4PrBlkthjJ4gmqdSPrFg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="],
@@ -278,6 +283,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
@@ -304,6 +311,8 @@
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
@@ -326,6 +335,8 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -334,6 +345,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
@@ -354,10 +367,14 @@
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
@@ -404,6 +421,8 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.0.0", "", {}, "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw=="],
@@ -422,6 +441,8 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
@@ -542,6 +563,8 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rolldown": ["rolldown@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@oxc-project/runtime": "0.71.0", "@oxc-project/types": "0.71.0", "@rolldown/pluginutils": "1.0.0-beta.9-commit.d91dfb5", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-darwin-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9-commit.d91dfb5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g=="],
@@ -550,6 +573,8 @@
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -560,15 +585,21 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"svelte": ["svelte@5.46.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA=="],
@@ -594,6 +625,8 @@
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -606,6 +639,8 @@
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unplugin-icons": ["unplugin-icons@22.5.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^3.0.2", "debug": "^4.4.3", "local-pkg": "^1.1.2", "unplugin": "^2.3.10" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2 || ^2.7.0", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", "vue-template-compiler": "^2.6.12", "vue-template-es2015-compiler": "^1.9.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "svelte", "vue-template-compiler", "vue-template-es2015-compiler"] }, "sha512-MBlMtT5RuMYZy4TZgqUL2OTtOdTUVsS1Mhj6G1pEzMlFJlEnq6mhUfoIt45gBWxHcsOdXJDWLg3pRZ+YmvAVWQ=="],
@@ -624,8 +659,16 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
@@ -650,6 +693,8 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
+3
View File
@@ -16,6 +16,7 @@
"@fontsource-variable/inter": "^5.1.0",
"@fontsource/hanken-grotesk": "^5.1.0",
"@fontsource/schibsted-grotesk": "^5.2.8",
"@logtape/logtape": "^1.3.5",
"bits-ui": "^2.8.2",
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.1"
@@ -26,6 +27,8 @@
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^25.0.3",
"concurrently": "^9.2.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
+40
View File
@@ -0,0 +1,40 @@
import type { Handle, HandleServerError } from "@sveltejs/kit";
import { dev } from "$app/environment";
import { initLogger } from "$lib/logger";
import { getLogger } from "@logtape/logtape";
// Initialize logger on server startup
await initLogger();
const logger = getLogger(["ssr", "error"]);
export const handle: Handle = async ({ event, resolve }) => {
// Handle DevTools request silently to prevent console.log spam
if (
dev &&
event.url.pathname === "/.well-known/appspecific/com.chrome.devtools.json"
) {
return new Response(undefined, { status: 404 });
}
return await resolve(event);
};
export const handleError: HandleServerError = async ({
error,
event,
status,
message,
}) => {
// Use structured logging via LogTape instead of console.error
logger.error(message, {
status,
method: event.request.method,
path: event.url.pathname,
error: error instanceof Error ? error.message : String(error),
});
return {
message: status === 404 ? "Not Found" : "Internal Error",
};
};
+75
View File
@@ -0,0 +1,75 @@
import { getLogger } from "@logtape/logtape";
import { env } from "$env/dynamic/private";
const logger = getLogger(["ssr", "lib", "api"]);
// Compute upstream configuration once at module load
const upstreamUrl = env.UPSTREAM_URL;
const isUnixSocket =
upstreamUrl?.startsWith("/") || upstreamUrl?.startsWith("./");
const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl;
/**
* Fetch utility for calling the Rust backend API.
* Automatically prefixes requests with the upstream URL from environment.
* Supports both HTTP URLs and Unix socket paths.
*
* Connection pooling and keep-alive are handled automatically by Bun.
* Default timeout is 30 seconds unless overridden via init.signal.
*/
export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
if (!upstreamUrl) {
logger.error("UPSTREAM_URL environment variable not set");
throw new Error("UPSTREAM_URL environment variable not set");
}
const url = `${baseUrl}${path}`;
const method = init?.method ?? "GET";
// Build fetch options with 30s default timeout and unix socket support
const fetchOptions: RequestInit & { unix?: string } = {
...init,
// Respect caller-provided signal, otherwise default to 30s timeout
signal: init?.signal ?? AbortSignal.timeout(30_000),
};
if (isUnixSocket) {
fetchOptions.unix = upstreamUrl;
}
logger.debug("API request", {
method,
url,
path,
isUnixSocket,
upstreamUrl,
});
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
logger.error("API request failed", {
method,
url,
status: response.status,
statusText: response.statusText,
});
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
logger.debug("API response", { method, url, status: response.status });
return data;
} catch (error) {
logger.error("API request exception", {
method,
url,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { configure, getConsoleSink, type LogRecord } from "@logtape/logtape";
interface RailwayLogEntry {
timestamp: string;
level: string;
message: string;
target: string;
[key: string]: unknown;
}
/**
* Custom formatter that outputs Railway-compatible JSON logs.
* Format: { timestamp, level, message, target, ...attributes }
*
* The target field is constructed from the logger category:
* - ["ssr"] -> "ssr"
* - ["ssr", "routes"] -> "ssr:routes"
* - ["ssr", "api", "auth"] -> "ssr:api:auth"
*/
function railwayFormatter(record: LogRecord): string {
const entry: RailwayLogEntry = {
timestamp: new Date().toISOString(),
level: record.level.toLowerCase(),
message: record.message.join(" "),
target: record.category.join(":"),
};
// Flatten properties to root level (custom attributes)
if (record.properties && Object.keys(record.properties).length > 0) {
Object.assign(entry, record.properties);
}
return JSON.stringify(entry) + "\n";
}
/**
* Initialize LogTape with Railway-compatible JSON logging.
* Only outputs logs when LOG_JSON=true or LOG_JSON=1 is set.
* Safe to call multiple times (idempotent - will silently skip if already configured).
*/
export async function initLogger() {
const useJsonLogs =
process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
try {
if (!useJsonLogs) {
// In development, use default console logging with nice formatting
await configure({
sinks: {
console: getConsoleSink(),
},
filters: {},
loggers: [
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["console"],
},
{
category: [],
lowestLevel: "debug",
sinks: ["console"],
},
],
});
return;
}
// In production/JSON mode, use Railway-compatible JSON formatter
await configure({
sinks: {
json: (record: LogRecord) => {
process.stdout.write(railwayFormatter(record));
},
},
filters: {},
loggers: [
// Meta logger for LogTape's internal messages
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["json"],
},
// SSR application logs
{
category: ["ssr"],
lowestLevel: "info",
sinks: ["json"],
},
],
});
} catch (error) {
// Already configured (HMR in dev mode), silently ignore
if (
error instanceof Error &&
error.message.includes("Already configured")
) {
return;
}
throw error;
}
}
+1 -6
View File
@@ -5,17 +5,12 @@
import IconSimpleIconsDiscord from "~icons/simple-icons/discord";
import MaterialSymbolsMailRounded from "~icons/material-symbols/mail-rounded";
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
// import IconLucideRss from "~icons/lucide/rss";
</script>
<AppWrapper class="overflow-x-hidden font-schibsted">
<!-- Top Navigation Bar -->
<div class="flex w-full justify-end items-center pt-5 px-6 pb-9">
<div class="flex gap-4 items-center">
<!-- <a href="/rss" class="text-zinc-400 hover:text-zinc-200">
<IconLucideRss class="size-5" />
</a> -->
</div>
<!-- <div class="flex gap-4 items-center"></div> -->
</div>
<!-- Main Content -->
+3 -2
View File
@@ -1,4 +1,5 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api";
interface ProjectLink {
url: string;
@@ -14,8 +15,8 @@ export interface Project {
}
export const load: PageServerLoad = async () => {
// TODO: Fetch from Rust backend API
const projects = await apiFetch<Project[]>("/api/projects");
return {
projects: [] as Project[],
projects,
};
};
+1
View File
@@ -9,6 +9,7 @@ const config = {
adapter: adapter({
out: "build",
precompress: false,
serveAssets: false,
}),
alias: {
$components: "src/lib/components",
+170
View File
@@ -0,0 +1,170 @@
import type { Plugin, ViteDevServer } from "vite";
import { configure, getLogger, type LogRecord } from "@logtape/logtape";
interface RailwayLogEntry {
timestamp: string;
level: string;
message: string;
target: string;
[key: string]: unknown;
}
/**
* Railway-compatible JSON formatter for Vite logs
*/
function railwayFormatter(record: LogRecord): string {
const entry: RailwayLogEntry = {
timestamp: new Date().toISOString(),
level: record.level.toLowerCase(),
message: record.message.join(" "),
target: "vite",
};
// Flatten properties to root level
if (record.properties && Object.keys(record.properties).length > 0) {
Object.assign(entry, record.properties);
}
return JSON.stringify(entry) + "\n";
}
// Strip ANSI escape codes from strings
function stripAnsi(str: string): string {
return str.replace(/\u001b\[[0-9;]*m/g, "").trim();
}
export function jsonLogger(): Plugin {
const useJsonLogs =
process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
// If JSON logging is disabled, return a minimal plugin that does nothing
if (!useJsonLogs) {
return {
name: "vite-plugin-json-logger",
};
}
// Configure LogTape for Vite plugin logging
let loggerConfigured = false;
const configureLogger = async () => {
if (loggerConfigured) return;
await configure({
sinks: {
json: (record: LogRecord) => {
process.stdout.write(railwayFormatter(record));
},
},
filters: {},
loggers: [
// Suppress LogTape meta logger info messages
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["json"],
},
{
category: ["vite"],
lowestLevel: "debug",
sinks: ["json"],
},
],
});
loggerConfigured = true;
};
let server: ViteDevServer;
const ignoredMessages = new Set(["press h + enter to show help", "ready in"]);
return {
name: "vite-plugin-json-logger",
async config() {
await configureLogger();
const logger = getLogger(["vite"]);
return {
customLogger: {
info(msg: string) {
const cleaned = stripAnsi(msg);
// Filter out noise
if (
!cleaned ||
ignoredMessages.has(cleaned) ||
cleaned.includes("VITE v")
) {
return;
}
logger.info(cleaned);
},
warn(msg: string) {
const cleaned = stripAnsi(msg);
if (cleaned) {
logger.warn(cleaned);
}
},
error(msg: string) {
const cleaned = stripAnsi(msg);
if (cleaned) {
logger.error(cleaned);
}
},
clearScreen() {
// No-op since clearScreen is already false
},
hasErrorLogged() {
return false;
},
hasWarned: false,
warnOnce(msg: string) {
this.warn(msg);
},
},
};
},
configureServer(s) {
server = s;
const logger = getLogger(["vite"]);
// Override the default URL printing
const originalPrintUrls = server.printUrls;
server.printUrls = () => {
const urls = server.resolvedUrls;
if (urls) {
logger.info("dev server running", {
local: urls.local,
network: urls.network,
});
}
};
// Listen to server events
server.httpServer?.once("listening", () => {
logger.info("server listening");
});
server.ws.on("connection", () => {
logger.info("client connected");
});
},
handleHotUpdate({ file, modules }) {
const logger = getLogger(["vite"]);
logger.info("hmr update", {
file: file.replace(process.cwd(), ""),
modules: modules.length,
});
return modules;
},
buildStart() {
const logger = getLogger(["vite"]);
logger.info("build started");
},
buildEnd() {
const logger = getLogger(["vite"]);
logger.info("build ended");
},
};
}
+8 -1
View File
@@ -2,7 +2,14 @@ import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import Icons from "unplugin-icons/vite";
import { jsonLogger } from "./vite-plugin-json-logger";
export default defineConfig({
plugins: [tailwindcss(), sveltekit(), Icons({ compiler: "svelte" })],
plugins: [
jsonLogger(),
tailwindcss(),
sveltekit(),
Icons({ compiler: "svelte" }),
],
clearScreen: false,
});