+25
.env.example
+25
.env.example
···
50
50
# Use "*" to allow all origins (not recommended for production)
51
51
# Example: https://example.com,https://app.example.com
52
52
PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com"
53
+
54
+
# Cache TTL Configuration (optional)
55
+
# Configure how long different types of data are cached (in minutes)
56
+
# Longer TTLs reduce API calls and prevent timeouts, but data may be less fresh
57
+
# Leave empty to use defaults (optimized for production)
58
+
# Profile data (default: 5 min dev, 60 min prod)
59
+
# CACHE_TTL_PROFILE=60
60
+
# Site info (default: 5 min dev, 120 min prod)
61
+
# CACHE_TTL_SITE_INFO=120
62
+
# Links (default: 5 min dev, 60 min prod)
63
+
# CACHE_TTL_LINKS=60
64
+
# Music status (default: 2 min dev, 10 min prod)
65
+
# CACHE_TTL_MUSIC_STATUS=10
66
+
# Kibun status (default: 2 min dev, 15 min prod)
67
+
# CACHE_TTL_KIBUN_STATUS=15
68
+
# Tangled repos (default: 5 min dev, 60 min prod)
69
+
# CACHE_TTL_TANGLED_REPOS=60
70
+
# Blog posts (default: 5 min dev, 30 min prod)
71
+
# CACHE_TTL_BLOG_POSTS=30
72
+
# Publications (default: 5 min dev, 60 min prod)
73
+
# CACHE_TTL_PUBLICATIONS=60
74
+
# Individual posts (default: 5 min dev, 60 min prod)
75
+
# CACHE_TTL_INDIVIDUAL_POST=60
76
+
# Identity resolution (default: 30 min dev, 1440 min/24h prod)
77
+
# CACHE_TTL_IDENTITY=1440
-80
git_diff.txt
-80
git_diff.txt
···
1
-
diff --git a/src/lib/components/layout/main/card/ProfileCard.svelte b/src/lib/components/layout/main/card/ProfileCard.svelte
2
-
index dc23db8..5d6f030 100644
3
-
--- a/src/lib/components/layout/main/card/ProfileCard.svelte
4
-
+++ b/src/lib/components/layout/main/card/ProfileCard.svelte
5
-
@@ -112,6 +112,9 @@
6
-
{safeProfile.displayName || safeProfile.handle}
7
-
</h2>
8
-
<p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p>
9
-
+ {#if safeProfile.pronouns}
10
-
+ <p class="text-sm italic text-ink-600 dark:text-ink-300">{safeProfile.pronouns}</p>
11
-
+ {/if}
12
-
13
-
{#if safeProfile.description}
14
-
<p
15
-
diff --git a/src/lib/services/atproto/fetch.ts b/src/lib/services/atproto/fetch.ts
16
-
index c719682..853a0c2 100644
17
-
--- a/src/lib/services/atproto/fetch.ts
18
-
+++ b/src/lib/services/atproto/fetch.ts
19
-
@@ -40,6 +40,31 @@ export async function fetchProfile(fetchFn?: typeof fetch): Promise<ProfileData>
20
-
fetchFn
21
-
);
22
-
23
-
+ // Fetch the actual profile record to get pronouns and other fields
24
-
+ // The profile view doesn't include pronouns, so we need the record
25
-
+ let pronouns: string | undefined;
26
-
+ try {
27
-
+ console.debug('[Profile] Attempting to fetch profile record for pronouns');
28
-
+ const recordResponse = await withFallback(
29
-
+ PUBLIC_ATPROTO_DID,
30
-
+ async (agent) => {
31
-
+ const response = await agent.com.atproto.repo.getRecord({
32
-
+ repo: PUBLIC_ATPROTO_DID,
33
-
+ collection: 'app.bsky.actor.profile',
34
-
+ rkey: 'self'
35
-
+ });
36
-
+ return response.data;
37
-
+ },
38
-
+ false,
39
-
+ fetchFn
40
-
+ );
41
-
+ pronouns = (recordResponse.value as any).pronouns;
42
-
+ console.debug('[Profile] Successfully fetched pronouns:', pronouns);
43
-
+ } catch (error) {
44
-
+ console.debug('[Profile] Could not fetch profile record for pronouns:', error);
45
-
+ // Continue without pronouns if record fetch fails
46
-
+ }
47
-
+
48
-
const data: ProfileData = {
49
-
did: profile.did,
50
-
handle: profile.handle,
51
-
@@ -49,7 +74,8 @@ export async function fetchProfile(fetchFn?: typeof fetch): Promise<ProfileData>
52
-
banner: profile.banner,
53
-
followersCount: profile.followersCount,
54
-
followsCount: profile.followsCount,
55
-
- postsCount: profile.postsCount
56
-
+ postsCount: profile.postsCount,
57
-
+ pronouns: pronouns
58
-
};
59
-
60
-
console.info('[Profile] Successfully fetched profile data');
61
-
diff --git a/src/lib/services/atproto/types.ts b/src/lib/services/atproto/types.ts
62
-
index f4d4b16..37440f6 100644
63
-
--- a/src/lib/services/atproto/types.ts
64
-
+++ b/src/lib/services/atproto/types.ts
65
-
@@ -12,6 +12,7 @@ export interface ProfileData {
66
-
followersCount?: number;
67
-
followsCount?: number;
68
-
postsCount?: number;
69
-
+ pronouns?: string;
70
-
}
71
-
72
-
export interface StatusData {
73
-
@@ -150,6 +151,7 @@ export interface PostAuthor {
74
-
handle: string;
75
-
displayName?: string;
76
-
avatar?: string;
77
-
+ pronouns?: string;
78
-
}
79
-
80
-
export interface BlueskyPost {
+2
-2
package-lock.json
+2
-2
package-lock.json
···
1
1
{
2
2
"name": "website",
3
-
"version": "10.3.4",
3
+
"version": "10.5.0",
4
4
"lockfileVersion": 3,
5
5
"requires": true,
6
6
"packages": {
7
7
"": {
8
8
"name": "website",
9
-
"version": "10.3.4",
9
+
"version": "10.5.0",
10
10
"dependencies": {
11
11
"@atproto/api": "^0.18.1",
12
12
"@lucide/svelte": "^0.554.0",
+1
-1
package.json
+1
-1
package.json
+23
-1
src/hooks.server.ts
+23
-1
src/hooks.server.ts
···
1
1
import type { Handle } from '@sveltejs/kit';
2
2
import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public';
3
+
import { HTTP_CACHE_HEADERS } from '$lib/config/cache.config';
3
4
4
5
/**
5
6
* Global request handler with CORS support
···
31
32
32
33
const response = await resolve(event, {
33
34
filterSerializedResponseHeaders: (name) => {
34
-
return name === 'content-type' || name.startsWith('x-');
35
+
return name === 'content-type' || name === 'cache-control' || name.startsWith('x-');
35
36
}
36
37
});
38
+
39
+
// Add HTTP caching headers for better performance and reduced timeouts
40
+
// Layout data (root route) is cached aggressively since profile/site info changes infrequently
41
+
if (!event.url.pathname.startsWith('/api/')) {
42
+
// Root layout loads profile and site info - cache aggressively
43
+
if (event.url.pathname === '/' || event.url.pathname === '') {
44
+
response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT);
45
+
}
46
+
// Blog listing pages
47
+
else if (event.url.pathname.startsWith('/blog') || event.url.pathname.startsWith('/archive')) {
48
+
response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_LISTING);
49
+
}
50
+
// Individual blog post pages
51
+
else if (event.url.pathname.match(/^\/[a-z0-9-]+$/)) {
52
+
response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_POST);
53
+
}
54
+
// Other pages get moderate caching
55
+
else {
56
+
response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT);
57
+
}
58
+
}
37
59
38
60
// Add CORS headers for API routes
39
61
if (event.url.pathname.startsWith('/api/')) {
+95
src/lib/config/cache.config.ts
+95
src/lib/config/cache.config.ts
···
1
+
import { dev } from '$app/environment';
2
+
3
+
/**
4
+
* Cache configuration with environment-aware TTL values
5
+
*
6
+
* Development: Shorter TTLs for faster iteration
7
+
* Production: Longer TTLs to reduce API calls and prevent timeouts
8
+
*/
9
+
10
+
// Parse environment variable or use default (in milliseconds)
11
+
const getEnvTTL = (key: string, defaultMinutes: number): number => {
12
+
if (typeof process !== 'undefined' && process.env?.[key]) {
13
+
const minutes = parseInt(process.env[key], 10);
14
+
return isNaN(minutes) ? defaultMinutes * 60 * 1000 : minutes * 60 * 1000;
15
+
}
16
+
return defaultMinutes * 60 * 1000;
17
+
};
18
+
19
+
/**
20
+
* Default TTL values (in minutes) for different data types
21
+
*
22
+
* Profile data changes infrequently, so we can cache it longer
23
+
* Music and Kibun statuses change frequently, so shorter cache
24
+
*/
25
+
const DEFAULT_TTL = {
26
+
// Profile data: 60 minutes (changes infrequently)
27
+
PROFILE: dev ? 5 : 60,
28
+
29
+
// Site info: 120 minutes (rarely changes)
30
+
SITE_INFO: dev ? 5 : 120,
31
+
32
+
// Links: 60 minutes (changes occasionally)
33
+
LINKS: dev ? 5 : 60,
34
+
35
+
// Music status: 10 minutes (changes frequently)
36
+
MUSIC_STATUS: dev ? 2 : 10,
37
+
38
+
// Kibun status: 15 minutes (changes occasionally)
39
+
KIBUN_STATUS: dev ? 2 : 15,
40
+
41
+
// Tangled repos: 60 minutes (changes occasionally)
42
+
TANGLED_REPOS: dev ? 5 : 60,
43
+
44
+
// Blog posts: 30 minutes (balance between freshness and performance)
45
+
BLOG_POSTS: dev ? 5 : 30,
46
+
47
+
// Publications: 60 minutes (rarely changes)
48
+
PUBLICATIONS: dev ? 5 : 60,
49
+
50
+
// Individual posts: 60 minutes (content doesn't change)
51
+
INDIVIDUAL_POST: dev ? 5 : 60,
52
+
53
+
// Identity resolution: 1440 minutes (24 hours - DIDs are stable)
54
+
IDENTITY: dev ? 30 : 1440
55
+
};
56
+
57
+
/**
58
+
* Cache TTL configuration
59
+
* Values are loaded from environment variables with fallbacks to defaults
60
+
*/
61
+
export const CACHE_TTL = {
62
+
PROFILE: getEnvTTL('CACHE_TTL_PROFILE', DEFAULT_TTL.PROFILE),
63
+
SITE_INFO: getEnvTTL('CACHE_TTL_SITE_INFO', DEFAULT_TTL.SITE_INFO),
64
+
LINKS: getEnvTTL('CACHE_TTL_LINKS', DEFAULT_TTL.LINKS),
65
+
MUSIC_STATUS: getEnvTTL('CACHE_TTL_MUSIC_STATUS', DEFAULT_TTL.MUSIC_STATUS),
66
+
KIBUN_STATUS: getEnvTTL('CACHE_TTL_KIBUN_STATUS', DEFAULT_TTL.KIBUN_STATUS),
67
+
TANGLED_REPOS: getEnvTTL('CACHE_TTL_TANGLED_REPOS', DEFAULT_TTL.TANGLED_REPOS),
68
+
BLOG_POSTS: getEnvTTL('CACHE_TTL_BLOG_POSTS', DEFAULT_TTL.BLOG_POSTS),
69
+
PUBLICATIONS: getEnvTTL('CACHE_TTL_PUBLICATIONS', DEFAULT_TTL.PUBLICATIONS),
70
+
INDIVIDUAL_POST: getEnvTTL('CACHE_TTL_INDIVIDUAL_POST', DEFAULT_TTL.INDIVIDUAL_POST),
71
+
IDENTITY: getEnvTTL('CACHE_TTL_IDENTITY', DEFAULT_TTL.IDENTITY)
72
+
} as const;
73
+
74
+
/**
75
+
* HTTP Cache-Control header values for different routes
76
+
* These tell browsers and CDNs how long to cache responses
77
+
*
78
+
* Format: max-age=X (browser cache), s-maxage=Y (CDN cache), stale-while-revalidate=Z
79
+
*/
80
+
export const HTTP_CACHE_HEADERS = {
81
+
// Layout data (profile, site info) - cache aggressively
82
+
LAYOUT: `public, max-age=${CACHE_TTL.PROFILE / 1000}, s-maxage=${CACHE_TTL.PROFILE / 1000}, stale-while-revalidate=${CACHE_TTL.PROFILE / 1000}`,
83
+
84
+
// Blog posts listing - moderate caching
85
+
BLOG_LISTING: `public, max-age=${CACHE_TTL.BLOG_POSTS / 1000}, s-maxage=${CACHE_TTL.BLOG_POSTS / 1000}, stale-while-revalidate=${CACHE_TTL.BLOG_POSTS / 1000}`,
86
+
87
+
// Individual blog post - cache aggressively (content doesn't change)
88
+
BLOG_POST: `public, max-age=${CACHE_TTL.INDIVIDUAL_POST / 1000}, s-maxage=${CACHE_TTL.INDIVIDUAL_POST / 1000}, stale-while-revalidate=${CACHE_TTL.INDIVIDUAL_POST / 1000}`,
89
+
90
+
// Music status - short cache (changes frequently)
91
+
MUSIC_STATUS: `public, max-age=${CACHE_TTL.MUSIC_STATUS / 1000}, s-maxage=${CACHE_TTL.MUSIC_STATUS / 1000}, stale-while-revalidate=${CACHE_TTL.MUSIC_STATUS / 1000}`,
92
+
93
+
// API endpoints - moderate caching
94
+
API: `public, max-age=300, s-maxage=300, stale-while-revalidate=600`
95
+
} as const;
+14
src/lib/services/atproto/agents.ts
+14
src/lib/services/atproto/agents.ts
···
1
1
import { AtpAgent } from '@atproto/api';
2
2
import type { ResolvedIdentity } from './types';
3
+
import { cache } from './cache';
3
4
4
5
/**
5
6
* Creates an AtpAgent with optional fetch function injection
···
46
47
47
48
/**
48
49
* Resolves a DID to find its PDS endpoint using Slingshot.
50
+
* Results are cached to reduce resolution calls.
49
51
*/
50
52
export async function resolveIdentity(
51
53
did: string,
···
53
55
): Promise<ResolvedIdentity> {
54
56
console.info(`[Identity] Resolving DID: ${did}`);
55
57
58
+
// Check cache first
59
+
const cacheKey = `identity:${did}`;
60
+
const cached = cache.get<ResolvedIdentity>(cacheKey);
61
+
if (cached) {
62
+
console.info('[Identity] Using cached identity resolution');
63
+
return cached;
64
+
}
65
+
56
66
// Prefer an injected fetch (from SvelteKit load), fall back to global fetch
57
67
const _fetch = fetchFn ?? globalThis.fetch;
58
68
···
84
94
if (!data.did || !data.pds) {
85
95
throw new Error('Invalid response from identity resolver');
86
96
}
97
+
98
+
// Cache the resolved identity
99
+
console.info('[Identity] Caching resolved identity');
100
+
cache.set(cacheKey, data);
87
101
88
102
return data;
89
103
}
+35
-9
src/lib/services/atproto/cache.ts
+35
-9
src/lib/services/atproto/cache.ts
···
1
1
import type { CacheEntry } from './types';
2
+
import { CACHE_TTL } from '$lib/config/cache.config';
2
3
3
4
/**
4
-
* Simple in-memory cache with TTL support
5
+
* Simple in-memory cache with configurable TTL support
6
+
*
7
+
* TTL values are configured per data type in cache.config.ts
8
+
* and can be overridden via environment variables
5
9
*/
6
10
export class ATProtoCache {
7
11
private cache = new Map<string, CacheEntry<any>>();
8
-
private readonly TTL = 5 * 60 * 1000; // 5 minutes
12
+
13
+
/**
14
+
* Get TTL for a cache key based on its prefix
15
+
*/
16
+
private getTTL(key: string): number {
17
+
if (key.startsWith('profile:')) return CACHE_TTL.PROFILE;
18
+
if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO;
19
+
if (key.startsWith('links:')) return CACHE_TTL.LINKS;
20
+
if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS;
21
+
if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS;
22
+
if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS;
23
+
if (key.startsWith('blog-posts:')) return CACHE_TTL.BLOG_POSTS;
24
+
if (key.startsWith('publications:')) return CACHE_TTL.PUBLICATIONS;
25
+
if (key.startsWith('post:')) return CACHE_TTL.INDIVIDUAL_POST;
26
+
if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY;
27
+
28
+
// Default fallback (30 minutes)
29
+
return 30 * 60 * 1000;
30
+
}
9
31
10
32
get<T>(key: string): T | null {
11
-
console.debug(`[Cache] Getting key: ${key}`);
33
+
console.info(`[Cache] Getting key: ${key}`);
12
34
const entry = this.cache.get(key);
13
35
if (!entry) {
14
-
console.debug(`[Cache] Cache miss for key: ${key}`);
36
+
console.info(`[Cache] Cache miss for key: ${key}`);
15
37
return null;
16
38
}
17
39
18
-
if (Date.now() - entry.timestamp > this.TTL) {
19
-
console.debug(`[Cache] Entry expired for key: ${key}`);
40
+
const ttl = this.getTTL(key);
41
+
const age = Date.now() - entry.timestamp;
42
+
43
+
if (age > ttl) {
44
+
console.info(`[Cache] Entry expired for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`);
20
45
this.cache.delete(key);
21
46
return null;
22
47
}
23
48
24
-
console.debug(`[Cache] Cache hit for key: ${key}`);
49
+
console.info(`[Cache] Cache hit for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`);
25
50
return entry.data;
26
51
}
27
52
28
53
set<T>(key: string, data: T): void {
29
-
console.debug(`[Cache] Setting key: ${key}`, data);
54
+
const ttl = this.getTTL(key);
55
+
console.info(`[Cache] Setting key: ${key} (ttl: ${Math.round(ttl / 1000)}s)`);
30
56
this.cache.set(key, {
31
57
data,
32
58
timestamp: Date.now()
···
34
60
}
35
61
36
62
delete(key: string): void {
37
-
console.debug(`[Cache] Deleting key: ${key}`);
63
+
console.info(`[Cache] Deleting key: ${key}`);
38
64
this.cache.delete(key);
39
65
}
40
66
+1
-1
src/routes/+layout.svelte
+1
-1
src/routes/+layout.svelte
+13
-20
src/routes/+layout.ts
+13
-20
src/routes/+layout.ts
···
1
1
import type { LayoutLoad } from './$types';
2
2
import { createSiteMeta, type SiteMetadata, defaultSiteMeta } from '$lib/helper/siteMeta';
3
-
import { fetchProfile, fetchSiteInfo } from '$lib/services/atproto';
4
3
5
-
export const load: LayoutLoad = async ({ url, fetch }) => {
4
+
/**
5
+
* Non-blocking layout load
6
+
* Returns immediately with default site metadata
7
+
* All data fetching happens client-side in components for faster initial page load
8
+
*/
9
+
export const load: LayoutLoad = async ({ url }) => {
6
10
// Provide the default site metadata
7
11
const siteMeta: SiteMetadata = createSiteMeta({
8
12
title: defaultSiteMeta.title,
···
10
14
url: url.href // Include current URL for proper OG tags
11
15
});
12
16
13
-
// Fetch lightweight public data for layout using injected fetch
14
-
let profile = null;
15
-
let siteInfo = null;
16
-
17
-
try {
18
-
profile = await fetchProfile(fetch);
19
-
} catch (err) {
20
-
// Non-fatal: layout should still render even if profile fails
21
-
console.warn('Layout: failed to fetch profile in load', err);
22
-
}
23
-
24
-
try {
25
-
siteInfo = await fetchSiteInfo(fetch);
26
-
} catch (err) {
27
-
console.warn('Layout: failed to fetch siteInfo in load', err);
28
-
}
29
-
30
-
return { siteMeta, profile, siteInfo };
17
+
// Return immediately - no blocking data fetches
18
+
// Components will fetch their own data client-side with skeletons
19
+
return {
20
+
siteMeta,
21
+
profile: null,
22
+
siteInfo: null
23
+
};
31
24
};