mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 12:26:39 -06:00
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:
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user