mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 10:26:52 -06:00
feat: add health checks, OG image generation, and R2 integration
- Implement health check system with caching and singleflight pattern - Add OG image generation via Satori with R2 storage backend - Configure Railway deployment with health check endpoint - Add connection pooling and Unix socket support for Bun SSR - Block external access to internal routes (/internal/*)
This commit is contained in:
+12
-29
@@ -27,46 +27,29 @@ export async function initLogger() {
|
||||
const useJsonLogs =
|
||||
process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
|
||||
|
||||
try {
|
||||
if (!useJsonLogs) {
|
||||
await configure({
|
||||
sinks: {
|
||||
console: getConsoleSink(),
|
||||
},
|
||||
filters: {},
|
||||
loggers: [
|
||||
{
|
||||
category: ["logtape", "meta"],
|
||||
lowestLevel: "warning",
|
||||
sinks: ["console"],
|
||||
},
|
||||
{
|
||||
category: [],
|
||||
lowestLevel: "debug",
|
||||
sinks: ["console"],
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sinkName = useJsonLogs ? "json" : "console";
|
||||
const sink = useJsonLogs
|
||||
? (record: LogRecord) => {
|
||||
process.stdout.write(railwayFormatter(record));
|
||||
}
|
||||
: getConsoleSink();
|
||||
|
||||
try {
|
||||
await configure({
|
||||
sinks: {
|
||||
json: (record: LogRecord) => {
|
||||
process.stdout.write(railwayFormatter(record));
|
||||
},
|
||||
[sinkName]: sink,
|
||||
},
|
||||
filters: {},
|
||||
loggers: [
|
||||
{
|
||||
category: ["logtape", "meta"],
|
||||
lowestLevel: "warning",
|
||||
sinks: ["json"],
|
||||
sinks: [sinkName],
|
||||
},
|
||||
{
|
||||
category: ["ssr"],
|
||||
lowestLevel: "info",
|
||||
sinks: ["json"],
|
||||
category: [],
|
||||
lowestLevel: "debug",
|
||||
sinks: [sinkName],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { read } from "$app/server";
|
||||
import { CustomFont, resolveFonts } from "@ethercorps/sveltekit-og/fonts";
|
||||
import HankenGrotesk900 from "@fontsource/hanken-grotesk/files/hanken-grotesk-latin-900-normal.woff?url";
|
||||
import SchibstedGrotesk400 from "@fontsource/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff?url";
|
||||
import Inter500 from "@fontsource/inter/files/inter-latin-500-normal.woff?url";
|
||||
|
||||
/**
|
||||
* Load fonts for OG image generation.
|
||||
* Fonts are sourced from @fontsource packages and imported directly from node_modules.
|
||||
* Must be called on each request (fonts can't be cached globally in server context).
|
||||
*
|
||||
* Note: Only WOFF/TTF/OTF formats are supported by Satori (not WOFF2).
|
||||
*/
|
||||
export async function loadOGFonts() {
|
||||
const fonts = [
|
||||
new CustomFont(
|
||||
"Hanken Grotesk",
|
||||
() => read(HankenGrotesk900).arrayBuffer(),
|
||||
{
|
||||
weight: 900,
|
||||
style: "normal",
|
||||
},
|
||||
),
|
||||
new CustomFont(
|
||||
"Schibsted Grotesk",
|
||||
() => read(SchibstedGrotesk400).arrayBuffer(),
|
||||
{
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
),
|
||||
new CustomFont("Inter", () => read(Inter500).arrayBuffer(), {
|
||||
weight: 500,
|
||||
style: "normal",
|
||||
}),
|
||||
];
|
||||
|
||||
return await resolveFonts(fonts);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Generate OG image HTML template matching xevion.dev dark aesthetic.
|
||||
* Satori only supports flex layouts and subset of CSS.
|
||||
*/
|
||||
export function generateOGTemplate({
|
||||
title,
|
||||
subtitle,
|
||||
type = "default",
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
type?: "default" | "project";
|
||||
}): string {
|
||||
return `
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
background-color: #000000;
|
||||
color: #fafafa;
|
||||
font-family: 'Schibsted Grotesk', sans-serif;
|
||||
padding: 60px 80px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
"
|
||||
>
|
||||
<!-- Main Content -->
|
||||
<div style="display: flex; flex-direction: column; flex: 1; justify-content: center;">
|
||||
<h1
|
||||
style="
|
||||
font-family: 'Hanken Grotesk', sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: ${type === "project" ? "72px" : "96px"};
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
"
|
||||
>
|
||||
${escapeHtml(title)}
|
||||
</h1>
|
||||
${
|
||||
subtitle
|
||||
? `
|
||||
<p
|
||||
style="
|
||||
font-family: 'Schibsted Grotesk', sans-serif;
|
||||
font-size: 36px;
|
||||
margin: 32px 0 0 0;
|
||||
color: #a1a1aa;
|
||||
line-height: 1.4;
|
||||
"
|
||||
>
|
||||
${escapeHtml(subtitle)}
|
||||
</p>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
border-top: 2px solid #27272a;
|
||||
padding-top: 24px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-size: 28px;
|
||||
color: #71717a;
|
||||
font-weight: 500;
|
||||
"
|
||||
>
|
||||
xevion.dev
|
||||
</div>
|
||||
${
|
||||
type === "project"
|
||||
? `
|
||||
<div
|
||||
style="
|
||||
font-size: 24px;
|
||||
color: #52525b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
"
|
||||
>
|
||||
PROJECT
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Discriminated union of all OG image types.
|
||||
*
|
||||
* IMPORTANT: Keep in sync with Rust's OGImageSpec in src/og.rs
|
||||
*/
|
||||
export type OGImageSpec =
|
||||
| { type: "index" }
|
||||
| { type: "projects" }
|
||||
| { type: "project"; id: string };
|
||||
|
||||
/**
|
||||
* Generate the R2 public URL for an OG image.
|
||||
* Called at ISR/build time when generating page metadata.
|
||||
*
|
||||
* @param spec - The OG image specification
|
||||
* @returns Full URL to the R2-hosted image
|
||||
*/
|
||||
export function getOGImageUrl(spec: OGImageSpec): string {
|
||||
const R2_BASE = import.meta.env.VITE_OG_R2_BASE_URL;
|
||||
|
||||
if (!R2_BASE) {
|
||||
throw new Error("VITE_OG_R2_BASE_URL environment variable is not set");
|
||||
}
|
||||
|
||||
switch (spec.type) {
|
||||
case "index":
|
||||
return `${R2_BASE}/og/index.png`;
|
||||
case "projects":
|
||||
return `${R2_BASE}/og/projects.png`;
|
||||
case "project":
|
||||
return `${R2_BASE}/og/project/${spec.id}.png`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
import { getOGImageUrl } from "$lib/og-types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ url }) => {
|
||||
return {
|
||||
metadata: {
|
||||
title: "Xevion.dev",
|
||||
description:
|
||||
"The personal website of Xevion, a full-stack software developer.",
|
||||
ogImage: getOGImageUrl({ type: "index" }),
|
||||
url: url.toString(),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -6,16 +6,38 @@
|
||||
import "@fontsource/schibsted-grotesk/500.css";
|
||||
import "@fontsource/schibsted-grotesk/600.css";
|
||||
|
||||
let { children } = $props();
|
||||
let { children, data } = $props();
|
||||
|
||||
const metadata = data?.metadata ?? {
|
||||
title: "Xevion.dev",
|
||||
description:
|
||||
"The personal website of Xevion, a full-stack software developer.",
|
||||
ogImage: "/api/og/home.png",
|
||||
url: "https://xevion.dev",
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>Xevion.dev</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The personal website of Xevion, a full-stack software developer."
|
||||
/>
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{metadata.title}</title>
|
||||
<meta name="description" content={metadata.description} />
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={metadata.url} />
|
||||
<meta property="og:title" content={metadata.title} />
|
||||
<meta property="og:description" content={metadata.description} />
|
||||
<meta property="og:image" content={metadata.ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={metadata.title} />
|
||||
<meta name="twitter:description" content={metadata.description} />
|
||||
<meta name="twitter:image" content={metadata.ogImage} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { apiFetch } from "$lib/api";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger(["ssr", "routes", "internal", "health"]);
|
||||
|
||||
/**
|
||||
* Internal health check endpoint.
|
||||
* Called by Rust server to validate full round-trip connectivity.
|
||||
*
|
||||
* IMPORTANT: This endpoint should never be accessible externally.
|
||||
* It's blocked by the Rust ISR handler's /internal/* check.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
// Test connectivity to Rust API by fetching projects
|
||||
// Use a 5 second timeout for this health check
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const projects = await apiFetch("/api/projects", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Validate response shape
|
||||
if (!Array.isArray(projects)) {
|
||||
logger.error("Health check failed: /api/projects returned non-array");
|
||||
return new Response("Internal health check failed", { status: 503 });
|
||||
}
|
||||
|
||||
logger.debug("Health check passed", { projectCount: projects.length });
|
||||
return new Response("OK", { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error("Health check failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return new Response("Internal health check failed", { status: 503 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ImageResponse } from "@ethercorps/sveltekit-og";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { loadOGFonts } from "$lib/og-fonts";
|
||||
import { generateOGTemplate } from "$lib/og-template";
|
||||
import { apiFetch } from "$lib/api";
|
||||
import type { Project } from "../../projects/+page.server";
|
||||
import type { OGImageSpec } from "$lib/og-types";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger(["ssr", "routes", "internal", "ogp"]);
|
||||
|
||||
/**
|
||||
* Internal endpoint for OG image generation.
|
||||
* Called by Rust server via POST with OGImageSpec JSON body.
|
||||
*
|
||||
* IMPORTANT: This endpoint should never be accessible externally.
|
||||
* It's blocked by the Rust ISR handler's /internal/* check.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let spec: OGImageSpec;
|
||||
|
||||
try {
|
||||
spec = await request.json();
|
||||
} catch {
|
||||
logger.warn("Invalid JSON body received");
|
||||
return new Response("Invalid JSON body", { status: 400 });
|
||||
}
|
||||
|
||||
return await generateOGImage(spec);
|
||||
};
|
||||
|
||||
/**
|
||||
* GET handler for OG image generation using query parameters.
|
||||
* Supports: ?type=index, ?type=projects, ?type=project&id=<id>
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const type = url.searchParams.get("type");
|
||||
|
||||
if (!type) {
|
||||
logger.warn("Missing 'type' query parameter");
|
||||
return new Response("Missing 'type' query parameter", { status: 400 });
|
||||
}
|
||||
|
||||
let spec: OGImageSpec;
|
||||
|
||||
switch (type) {
|
||||
case "index":
|
||||
spec = { type: "index" };
|
||||
break;
|
||||
case "projects":
|
||||
spec = { type: "projects" };
|
||||
break;
|
||||
case "project": {
|
||||
const id = url.searchParams.get("id");
|
||||
if (!id) {
|
||||
logger.warn("Missing 'id' query parameter for project type");
|
||||
return new Response("Missing 'id' query parameter for project type", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
spec = { type: "project", id };
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.warn("Invalid 'type' query parameter", { type });
|
||||
return new Response(`Invalid 'type' query parameter: ${type}`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return await generateOGImage(spec);
|
||||
};
|
||||
|
||||
async function generateOGImage(spec: OGImageSpec): Promise<Response> {
|
||||
logger.info("Generating OG image", { spec });
|
||||
|
||||
const templateData = await getTemplateData(spec);
|
||||
|
||||
try {
|
||||
const fonts = await loadOGFonts();
|
||||
const html = generateOGTemplate(templateData);
|
||||
|
||||
const imageResponse = new ImageResponse(html, {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts,
|
||||
});
|
||||
|
||||
const imageBuffer = await imageResponse.arrayBuffer();
|
||||
|
||||
logger.info("OG image generated successfully", { spec });
|
||||
|
||||
return new Response(imageBuffer, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png" },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("OG image generation failed", {
|
||||
spec,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return new Response("Failed to generate image", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function getTemplateData(spec: OGImageSpec): Promise<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
type?: "default" | "project";
|
||||
}> {
|
||||
switch (spec.type) {
|
||||
case "index":
|
||||
return {
|
||||
title: "Ryan Walters",
|
||||
subtitle: "Full-Stack Software Engineer",
|
||||
type: "default",
|
||||
};
|
||||
case "projects":
|
||||
return {
|
||||
title: "Projects",
|
||||
subtitle: "created, maintained, or contributed to by me...",
|
||||
type: "default",
|
||||
};
|
||||
case "project":
|
||||
try {
|
||||
const projects = await apiFetch<Project[]>("/api/projects");
|
||||
const project = projects.find((p) => p.id === spec.id);
|
||||
if (project) {
|
||||
return {
|
||||
title: project.name,
|
||||
subtitle: project.shortDescription,
|
||||
type: "project",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch project", { id: spec.id, error });
|
||||
}
|
||||
return {
|
||||
title: "Project",
|
||||
subtitle: "View on xevion.dev",
|
||||
type: "project",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { apiFetch } from "$lib/api";
|
||||
import { getOGImageUrl } from "$lib/og-types";
|
||||
|
||||
interface ProjectLink {
|
||||
url: string;
|
||||
@@ -14,9 +15,15 @@ export interface Project {
|
||||
links: ProjectLink[];
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const projects = await apiFetch<Project[]>("/api/projects");
|
||||
return {
|
||||
projects,
|
||||
metadata: {
|
||||
title: "Projects | Xevion.dev",
|
||||
description: "...",
|
||||
ogImage: getOGImageUrl({ type: "projects" }),
|
||||
url: url.toString(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user