mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 12:26:39 -06:00
feat: add ISR cache with stale-while-revalidate pattern
Implements in-memory caching for SSR pages using moka with: - Configurable fresh/stale TTLs (60s/300s defaults) - Background refresh for stale entries - Cache invalidation on project/tag mutations - Pre-cached icon collections on startup - Skips cache for authenticated requests
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import type { Handle, HandleServerError } from "@sveltejs/kit";
|
||||
import { dev } from "$app/environment";
|
||||
import { initLogger } from "$lib/logger";
|
||||
import { preCacheCollections } from "$lib/server/icons";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { minify } from "html-minifier-terser";
|
||||
|
||||
await initLogger();
|
||||
|
||||
// Pre-cache icon collections before handling any requests
|
||||
await preCacheCollections();
|
||||
|
||||
const logger = getLogger(["ssr", "error"]);
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts" module>
|
||||
import { renderIconSVG } from "$lib/server/icons";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
class?: string;
|
||||
size?: number;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
icon,
|
||||
class: className,
|
||||
size,
|
||||
fallback = "lucide:help-circle",
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#await renderIconSVG(icon, { class: cn("inline-block", className), size })}
|
||||
<!-- Loading state during SSR (shouldn't be visible) -->
|
||||
{:then svg}
|
||||
{#if svg}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html svg}
|
||||
{:else}
|
||||
<!-- Fallback icon if primary fails -->
|
||||
{#await renderIconSVG( fallback, { class: cn("inline-block", className), size }, ) then fallbackSvg}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html fallbackSvg}
|
||||
{/await}
|
||||
{/if}
|
||||
{/await}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dev } from "$app/environment";
|
||||
import { configure, getConsoleSink, type LogRecord } from "@logtape/logtape";
|
||||
|
||||
interface RailwayLogEntry {
|
||||
@@ -27,6 +28,9 @@ export async function initLogger() {
|
||||
const useJsonLogs =
|
||||
process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
|
||||
|
||||
const logLevel = (process.env.LOG_LEVEL?.toLowerCase() ??
|
||||
(dev ? "debug" : "info")) as "debug" | "info" | "warning" | "error";
|
||||
|
||||
const jsonSink = (record: LogRecord) => {
|
||||
process.stdout.write(railwayFormatter(record));
|
||||
};
|
||||
@@ -47,7 +51,7 @@ export async function initLogger() {
|
||||
},
|
||||
{
|
||||
category: [],
|
||||
lowestLevel: "debug",
|
||||
lowestLevel: logLevel,
|
||||
sinks: [useJsonLogs ? "json" : "console"],
|
||||
},
|
||||
],
|
||||
|
||||
+211
-107
@@ -1,20 +1,16 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import type { IconifyJSON } from "@iconify/types";
|
||||
import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import type {
|
||||
IconCollection,
|
||||
IconData,
|
||||
IconIdentifier,
|
||||
IconRenderOptions,
|
||||
} from "$lib/types/icons";
|
||||
import type { IconCollection, IconIdentifier, IconRenderOptions } from "$lib/types/icons";
|
||||
|
||||
const logger = getLogger(["server", "icons"]);
|
||||
|
||||
// In-memory cache for icon collections
|
||||
// In-memory cache for loaded icon collections
|
||||
const collectionCache = new Map<string, IconifyJSON>();
|
||||
|
||||
// Loading promises to prevent concurrent loads of the same collection
|
||||
const loadingPromises = new Map<string, Promise<IconifyJSON | null>>();
|
||||
|
||||
// Collections to pre-cache on server startup
|
||||
const PRE_CACHE_COLLECTIONS = [
|
||||
"lucide",
|
||||
@@ -25,7 +21,7 @@ const PRE_CACHE_COLLECTIONS = [
|
||||
];
|
||||
|
||||
// Default fallback icon
|
||||
const DEFAULT_FALLBACK_ICON = "lucide:help-circle";
|
||||
const DEFAULT_FALLBACK_ICON: IconIdentifier = "lucide:help-circle";
|
||||
|
||||
/**
|
||||
* Parse icon identifier into collection and name
|
||||
@@ -41,26 +37,13 @@ function parseIdentifier(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load icon collection from @iconify/json
|
||||
* Load icon collection from disk via dynamic import (internal - no caching logic)
|
||||
*/
|
||||
async function loadCollection(collection: string): Promise<IconifyJSON | null> {
|
||||
// Check cache first
|
||||
if (collectionCache.has(collection)) {
|
||||
return collectionCache.get(collection)!;
|
||||
}
|
||||
|
||||
async function loadCollectionFromDisk(collection: string): Promise<IconifyJSON | null> {
|
||||
try {
|
||||
const iconifyJsonPath = join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"@iconify",
|
||||
"json",
|
||||
"json",
|
||||
`${collection}.json`,
|
||||
);
|
||||
|
||||
const data = await readFile(iconifyJsonPath, "utf-8");
|
||||
const iconSet: IconifyJSON = JSON.parse(data);
|
||||
// Dynamic import - Bun resolves the package path automatically
|
||||
const module = await import(`@iconify/json/json/${collection}.json`);
|
||||
const iconSet: IconifyJSON = module.default;
|
||||
|
||||
// Cache the collection
|
||||
collectionCache.set(collection, iconSet);
|
||||
@@ -79,9 +62,203 @@ async function loadCollection(collection: string): Promise<IconifyJSON | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon data by identifier
|
||||
* Load icon collection with caching and concurrent load protection.
|
||||
* Multiple concurrent requests for the same collection will wait for a single load.
|
||||
*/
|
||||
export async function getIcon(identifier: string): Promise<IconData | null> {
|
||||
async function loadCollection(collection: string): Promise<IconifyJSON | null> {
|
||||
// Return cached if available
|
||||
if (collectionCache.has(collection)) {
|
||||
return collectionCache.get(collection)!;
|
||||
}
|
||||
|
||||
// Wait for in-progress load if another request is already loading this collection
|
||||
const existingPromise = loadingPromises.get(collection);
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
// Start new load and store promise so concurrent requests can wait
|
||||
const loadPromise = loadCollectionFromDisk(collection);
|
||||
loadingPromises.set(collection, loadPromise);
|
||||
|
||||
try {
|
||||
return await loadPromise;
|
||||
} finally {
|
||||
loadingPromises.delete(collection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render icon data to SVG string (internal)
|
||||
*/
|
||||
function renderIconData(
|
||||
iconData: ReturnType<typeof getIconData>,
|
||||
options: IconRenderOptions = {},
|
||||
): string {
|
||||
if (!iconData) {
|
||||
throw new Error("Icon data is null");
|
||||
}
|
||||
|
||||
// Convert icon data to SVG attributes
|
||||
const renderData = iconToSVG(iconData);
|
||||
|
||||
// Get SVG body
|
||||
const body = replaceIDs(iconData.body);
|
||||
|
||||
// Build SVG element with options applied
|
||||
const attributes: Record<string, string> = {
|
||||
...renderData.attributes,
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink",
|
||||
};
|
||||
|
||||
if (options.class) {
|
||||
attributes.class = options.class;
|
||||
}
|
||||
if (options.size) {
|
||||
attributes.width = String(options.size);
|
||||
attributes.height = String(options.size);
|
||||
}
|
||||
|
||||
const attributeString = Object.entries(attributes)
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(" ");
|
||||
|
||||
let svg = `<svg ${attributeString}>${body}</svg>`;
|
||||
|
||||
// Apply custom color (replace currentColor)
|
||||
if (options.color) {
|
||||
svg = svg.replace(/currentColor/g, options.color);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the default fallback icon (internal helper)
|
||||
*/
|
||||
async function renderFallbackIcon(options: IconRenderOptions): Promise<string | null> {
|
||||
const parsed = parseIdentifier(DEFAULT_FALLBACK_ICON);
|
||||
if (!parsed) return null;
|
||||
|
||||
const iconSet = await loadCollection(parsed.collection);
|
||||
if (!iconSet) return null;
|
||||
|
||||
const iconData = getIconData(iconSet, parsed.name);
|
||||
if (!iconData) return null;
|
||||
|
||||
return renderIconData(iconData, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render multiple icons efficiently in a single batch.
|
||||
* Groups icons by collection, loads each collection once, then renders all icons.
|
||||
*
|
||||
* @param identifiers - Array of icon identifiers (e.g., ["lucide:home", "simple-icons:github"])
|
||||
* @param options - Render options applied to all icons
|
||||
* @returns Map of identifier to rendered SVG string (missing icons get fallback)
|
||||
*/
|
||||
export async function renderIconsBatch(
|
||||
identifiers: string[],
|
||||
options: IconRenderOptions = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const results = new Map<string, string>();
|
||||
|
||||
if (identifiers.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Parse and group by collection
|
||||
const byCollection = new Map<string, { identifier: string; name: string }[]>();
|
||||
const invalidIdentifiers: string[] = [];
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
const parsed = parseIdentifier(identifier);
|
||||
if (!parsed) {
|
||||
invalidIdentifiers.push(identifier);
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = byCollection.get(parsed.collection) || [];
|
||||
group.push({ identifier, name: parsed.name });
|
||||
byCollection.set(parsed.collection, group);
|
||||
}
|
||||
|
||||
if (invalidIdentifiers.length > 0) {
|
||||
logger.warn("Invalid icon identifiers in batch", { identifiers: invalidIdentifiers });
|
||||
}
|
||||
|
||||
// Load all needed collections in parallel
|
||||
const collections = Array.from(byCollection.keys());
|
||||
const loadedCollections = await Promise.all(
|
||||
collections.map(async (collection) => ({
|
||||
collection,
|
||||
iconSet: await loadCollection(collection),
|
||||
})),
|
||||
);
|
||||
|
||||
// Build lookup map
|
||||
const collectionMap = new Map<string, IconifyJSON>();
|
||||
for (const { collection, iconSet } of loadedCollections) {
|
||||
if (iconSet) {
|
||||
collectionMap.set(collection, iconSet);
|
||||
}
|
||||
}
|
||||
|
||||
// Render all icons
|
||||
const missingIcons: string[] = [];
|
||||
|
||||
for (const [collection, icons] of byCollection) {
|
||||
const iconSet = collectionMap.get(collection);
|
||||
if (!iconSet) {
|
||||
missingIcons.push(...icons.map((i) => i.identifier));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { identifier, name } of icons) {
|
||||
const iconData = getIconData(iconSet, name);
|
||||
if (!iconData) {
|
||||
missingIcons.push(identifier);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const svg = renderIconData(iconData, options);
|
||||
results.set(identifier, svg);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to render icon: ${identifier}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
missingIcons.push(identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback for missing icons
|
||||
if (missingIcons.length > 0) {
|
||||
logger.warn("Icons not found in batch, using fallback", {
|
||||
missing: missingIcons,
|
||||
fallback: DEFAULT_FALLBACK_ICON,
|
||||
});
|
||||
|
||||
// Render fallback icon once
|
||||
const fallbackSvg = await renderFallbackIcon(options);
|
||||
if (fallbackSvg) {
|
||||
for (const identifier of missingIcons) {
|
||||
results.set(identifier, fallbackSvg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single icon data (for API endpoint use only)
|
||||
*/
|
||||
export async function getIconForApi(
|
||||
identifier: string,
|
||||
): Promise<{ identifier: string; collection: string; name: string; svg: string } | null> {
|
||||
const parsed = parseIdentifier(identifier);
|
||||
if (!parsed) {
|
||||
logger.warn(`Invalid icon identifier: ${identifier}`);
|
||||
@@ -95,14 +272,12 @@ export async function getIcon(identifier: string): Promise<IconData | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get icon data from the set
|
||||
const iconData = getIconData(iconSet, name);
|
||||
if (!iconData) {
|
||||
logger.warn(`Icon not found: ${identifier}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build SVG
|
||||
const svg = renderIconData(iconData);
|
||||
|
||||
return {
|
||||
@@ -114,74 +289,7 @@ export async function getIcon(identifier: string): Promise<IconData | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render icon data to SVG string
|
||||
*/
|
||||
function renderIconData(iconData: ReturnType<typeof getIconData>): string {
|
||||
if (!iconData) {
|
||||
throw new Error("Icon data is null");
|
||||
}
|
||||
|
||||
// Convert icon data to SVG attributes
|
||||
const renderData = iconToSVG(iconData);
|
||||
|
||||
// Get SVG body
|
||||
const body = replaceIDs(iconData.body);
|
||||
|
||||
// Build SVG element
|
||||
const attributes = {
|
||||
...renderData.attributes,
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink",
|
||||
};
|
||||
|
||||
const attributeString = Object.entries(attributes)
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(" ");
|
||||
|
||||
return `<svg ${attributeString}>${body}</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render icon SVG with custom options
|
||||
*/
|
||||
export async function renderIconSVG(
|
||||
identifier: string,
|
||||
options: IconRenderOptions = {},
|
||||
): Promise<string | null> {
|
||||
const iconData = await getIcon(identifier);
|
||||
|
||||
if (!iconData) {
|
||||
// Try fallback icon if provided, otherwise use default
|
||||
if (identifier !== DEFAULT_FALLBACK_ICON) {
|
||||
logger.warn(`Icon not found, using fallback: ${identifier}`);
|
||||
return renderIconSVG(DEFAULT_FALLBACK_ICON, options);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let svg = iconData.svg;
|
||||
|
||||
// Apply custom class
|
||||
if (options.class) {
|
||||
svg = svg.replace("<svg ", `<svg class="${options.class}" `);
|
||||
}
|
||||
|
||||
// Apply custom size
|
||||
if (options.size) {
|
||||
svg = svg.replace(/width="[^"]*"/, `width="${options.size}"`);
|
||||
svg = svg.replace(/height="[^"]*"/, `height="${options.size}"`);
|
||||
}
|
||||
|
||||
// Apply custom color (replace currentColor)
|
||||
if (options.color) {
|
||||
svg = svg.replace(/currentColor/g, options.color);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available collections
|
||||
* Get all available collections with metadata
|
||||
*/
|
||||
export async function getCollections(): Promise<IconCollection[]> {
|
||||
const collections: IconCollection[] = [];
|
||||
@@ -210,8 +318,7 @@ export async function searchIcons(
|
||||
query: string,
|
||||
limit: number = 50,
|
||||
): Promise<{ identifier: string; collection: string; name: string }[]> {
|
||||
const results: { identifier: string; collection: string; name: string }[] =
|
||||
[];
|
||||
const results: { identifier: string; collection: string; name: string }[] = [];
|
||||
|
||||
// Parse query for collection prefix (e.g., "lucide:home" or "lucide:")
|
||||
const colonIndex = query.indexOf(":");
|
||||
@@ -254,7 +361,8 @@ export async function searchIcons(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-cache common icon collections on server startup
|
||||
* Pre-cache common icon collections on server startup.
|
||||
* Call this in hooks.server.ts before handling requests.
|
||||
*/
|
||||
export async function preCacheCollections(): Promise<void> {
|
||||
logger.info("Pre-caching icon collections...", {
|
||||
@@ -270,7 +378,3 @@ export async function preCacheCollections(): Promise<void> {
|
||||
cached: collectionCache.size,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Future enhancement - Support color customization in icon identifiers
|
||||
// Format idea: "lucide:home#color=blue-500" or separate color field in DB
|
||||
// Would allow per-project icon theming without hardcoded styles
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { apiFetch } from "$lib/api.server";
|
||||
import { renderIconSVG } from "$lib/server/icons";
|
||||
import { renderIconsBatch } from "$lib/server/icons";
|
||||
import type { AdminProject } from "$lib/admin-types";
|
||||
|
||||
const CLOCK_ICON = "lucide:clock";
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, parent }) => {
|
||||
// Get settings from parent layout
|
||||
const parentData = await parent();
|
||||
@@ -10,36 +12,50 @@ export const load: PageServerLoad = async ({ fetch, parent }) => {
|
||||
|
||||
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
||||
|
||||
// Pre-render tag icons and clock icons (server-side only)
|
||||
const projectsWithIcons = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const tagsWithIcons = await Promise.all(
|
||||
project.tags.map(async (tag) => ({
|
||||
...tag,
|
||||
iconSvg: tag.icon
|
||||
? (await renderIconSVG(tag.icon, { size: 12 })) || ""
|
||||
: "",
|
||||
})),
|
||||
);
|
||||
// Collect all icon identifiers for batch rendering
|
||||
const smallIconIds = new Set<string>();
|
||||
const largeIconIds = new Set<string>();
|
||||
|
||||
const clockIconSvg =
|
||||
(await renderIconSVG("lucide:clock", { size: 12 })) || "";
|
||||
// Add static icons
|
||||
smallIconIds.add(CLOCK_ICON);
|
||||
|
||||
return {
|
||||
...project,
|
||||
tags: tagsWithIcons,
|
||||
clockIconSvg,
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Collect tag icons (size 12)
|
||||
for (const project of projects) {
|
||||
for (const tag of project.tags) {
|
||||
if (tag.icon) {
|
||||
smallIconIds.add(tag.icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-render social link icons (server-side only)
|
||||
const socialLinksWithIcons = await Promise.all(
|
||||
settings.socialLinks.map(async (link) => ({
|
||||
...link,
|
||||
iconSvg: (await renderIconSVG(link.icon, { size: 16 })) || "",
|
||||
// Collect social link icons (size 16)
|
||||
for (const link of settings.socialLinks) {
|
||||
if (link.icon) {
|
||||
largeIconIds.add(link.icon);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch render all icons (two batches for different sizes)
|
||||
const [smallIcons, largeIcons] = await Promise.all([
|
||||
renderIconsBatch([...smallIconIds], { size: 12 }),
|
||||
renderIconsBatch([...largeIconIds], { size: 16 }),
|
||||
]);
|
||||
|
||||
// Map icons back to projects
|
||||
const projectsWithIcons = projects.map((project) => ({
|
||||
...project,
|
||||
tags: project.tags.map((tag) => ({
|
||||
...tag,
|
||||
iconSvg: tag.icon ? smallIcons.get(tag.icon) ?? "" : "",
|
||||
})),
|
||||
);
|
||||
clockIconSvg: smallIcons.get(CLOCK_ICON) ?? "",
|
||||
}));
|
||||
|
||||
// Map icons back to social links
|
||||
const socialLinksWithIcons = settings.socialLinks.map((link) => ({
|
||||
...link,
|
||||
iconSvg: largeIcons.get(link.icon) ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
projects: projectsWithIcons,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json, error } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { requireAuth } from "$lib/server/auth";
|
||||
import { getIcon } from "$lib/server/icons";
|
||||
import { getIconForApi } from "$lib/server/icons";
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
// Require authentication
|
||||
@@ -10,7 +10,7 @@ export const GET: RequestHandler = async (event) => {
|
||||
const { collection, name } = event.params;
|
||||
const identifier = `${collection}:${name}`;
|
||||
|
||||
const iconData = await getIcon(identifier);
|
||||
const iconData = await getIconForApi(identifier);
|
||||
|
||||
if (!iconData) {
|
||||
throw error(404, `Icon not found: ${identifier}`);
|
||||
|
||||
Reference in New Issue
Block a user