feat: add Docker containerization with multi-stage build

Includes .dockerignore, Dockerfile with cargo-chef caching, and Justfile commands for building/running containerized app. Updates console-logger to support both JSON and pretty-printed logs based on LOG_JSON env var.
This commit is contained in:
2026-01-04 20:06:56 -06:00
parent edf271bcc6
commit 9de3c84f00
15 changed files with 238 additions and 244 deletions
+35 -20
View File
@@ -1,6 +1,3 @@
// Patch console methods to output structured JSON logs
// This runs before the Bun server starts to ensure all console output is formatted
const originalConsole = {
log: console.log,
error: console.error,
@@ -9,23 +6,41 @@ const originalConsole = {
debug: console.debug,
};
const useJson = process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
function formatLog(level, args) {
const message = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
).join(' ');
const logEntry = {
timestamp: new Date().toISOString(),
level: level,
message: message,
target: 'bun',
};
originalConsole.log(JSON.stringify(logEntry));
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
.join(" ");
if (useJson) {
const logEntry = {
timestamp: new Date().toISOString(),
level: level,
message: message,
target: "bun",
};
originalConsole.log(JSON.stringify(logEntry));
} else {
const timestamp = new Date().toISOString().split("T")[1].slice(0, 12);
const levelColors = {
debug: "\x1b[36m", // cyan
info: "\x1b[32m", // green
warn: "\x1b[33m", // yellow
error: "\x1b[31m", // red
};
const color = levelColors[level] || "";
const reset = "\x1b[0m";
const gray = "\x1b[90m";
originalConsole.log(
`${gray}${timestamp}${reset} ${color}${level.toUpperCase().padEnd(5)}${reset} ${gray}bun${reset}: ${message}`,
);
}
}
console.log = (...args) => formatLog('info', args);
console.info = (...args) => formatLog('info', args);
console.warn = (...args) => formatLog('warn', args);
console.error = (...args) => formatLog('error', args);
console.debug = (...args) => formatLog('debug', args);
console.log = (...args) => formatLog("info", args);
console.info = (...args) => formatLog("info", args);
console.warn = (...args) => formatLog("warn", args);
console.error = (...args) => formatLog("error", args);
console.debug = (...args) => formatLog("debug", args);
-3
View File
@@ -3,13 +3,11 @@ 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"
@@ -26,7 +24,6 @@ export const handleError: HandleServerError = async ({
status,
message,
}) => {
// Use structured logging via LogTape instead of console.error
logger.error(message, {
status,
method: event.request.method,
-11
View File
@@ -3,20 +3,11 @@ 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,
@@ -29,10 +20,8 @@ export async function apiFetch<T>(
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),
};
-4
View File
@@ -195,7 +195,6 @@
float closestRad = 0.0;
float pointOpacity = 0.0;
// Check 9 neighboring grid points
for (float dx = -1.0; dx <= 1.0; dx += 1.0) {
for (float dy = -1.0; dy <= 1.0; dy += 1.0) {
vec2 testGrid = gridCoord + vec2(dx * spacing, dy * spacing);
@@ -296,7 +295,6 @@
const { gl, program } = context;
// Setup fullscreen quad geometry
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
@@ -309,7 +307,6 @@
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
// Setup uniform manager
const uniforms = new UniformManager(gl, program, [
"u_resolution",
"u_time",
@@ -335,7 +332,6 @@
const dpr = window.devicePixelRatio || 1;
// Set static uniforms
uniforms.setStatic({
u_seed: Math.random() * 1000,
u_dpr: dpr,
-20
View File
@@ -8,15 +8,6 @@ interface RailwayLogEntry {
[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(),
@@ -25,7 +16,6 @@ function railwayFormatter(record: LogRecord): string {
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);
}
@@ -33,18 +23,12 @@ function railwayFormatter(record: LogRecord): string {
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(),
@@ -66,7 +50,6 @@ export async function initLogger() {
return;
}
// In production/JSON mode, use Railway-compatible JSON formatter
await configure({
sinks: {
json: (record: LogRecord) => {
@@ -75,13 +58,11 @@ export async function initLogger() {
},
filters: {},
loggers: [
// Meta logger for LogTape's internal messages
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["json"],
},
// SSR application logs
{
category: ["ssr"],
lowestLevel: "info",
@@ -90,7 +71,6 @@ export async function initLogger() {
],
});
} catch (error) {
// Already configured (HMR in dev mode), silently ignore
if (
error instanceof Error &&
error.message.includes("Already configured")
+1 -6
View File
@@ -8,17 +8,12 @@
</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"></div> -->
</div>
<div class="flex w-full justify-end items-center pt-5 px-6 pb-9"></div>
<!-- Main Content -->
<div class="flex items-center flex-col">
<div
class="max-w-2xl mx-6 border-b border-zinc-700 divide-y divide-zinc-700"
>
<!-- Name & Occupation -->
<div class="flex flex-col pb-4">
<span class="text-3xl font-bold text-white">Ryan Walters,</span>
<span class="text-2xl font-normal text-zinc-400">
+1 -14
View File
@@ -9,9 +9,6 @@ interface RailwayLogEntry {
[key: string]: unknown;
}
/**
* Railway-compatible JSON formatter for Vite logs
*/
function railwayFormatter(record: LogRecord): string {
const entry: RailwayLogEntry = {
timestamp: new Date().toISOString(),
@@ -20,7 +17,6 @@ function railwayFormatter(record: LogRecord): string {
target: "vite",
};
// Flatten properties to root level
if (record.properties && Object.keys(record.properties).length > 0) {
Object.assign(entry, record.properties);
}
@@ -28,7 +24,6 @@ function railwayFormatter(record: LogRecord): string {
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();
}
@@ -37,14 +32,12 @@ 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;
@@ -56,7 +49,6 @@ export function jsonLogger(): Plugin {
},
filters: {},
loggers: [
// Suppress LogTape meta logger info messages
{
category: ["logtape", "meta"],
lowestLevel: "warning",
@@ -86,7 +78,6 @@ export function jsonLogger(): Plugin {
customLogger: {
info(msg: string) {
const cleaned = stripAnsi(msg);
// Filter out noise
if (
!cleaned ||
ignoredMessages.has(cleaned) ||
@@ -108,9 +99,7 @@ export function jsonLogger(): Plugin {
logger.error(cleaned);
}
},
clearScreen() {
// No-op since clearScreen is already false
},
clearScreen() {},
hasErrorLogged() {
return false;
},
@@ -126,7 +115,6 @@ export function jsonLogger(): Plugin {
server = s;
const logger = getLogger(["vite"]);
// Override the default URL printing
const originalPrintUrls = server.printUrls;
server.printUrls = () => {
const urls = server.resolvedUrls;
@@ -138,7 +126,6 @@ export function jsonLogger(): Plugin {
}
};
// Listen to server events
server.httpServer?.once("listening", () => {
logger.info("server listening");
});