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
+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,
};
};