Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
pull_request:
push:

jobs:
ci:
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run CI checks
run: bun run ci
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"!**/node_modules",
"!build",
"!.svelte-kit",
"!.vercel",
"!package",
"!coverage",
"!**/.env",
Expand Down
34 changes: 25 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"ci": "biome ci . && bun run test && bun run check && bun run build",
"lint": "biome check",
"lint:write": "biome check --write",
"format": "biome format --write",
Expand All @@ -23,7 +24,7 @@
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.1.18",
"@types/dompurify": "^3.2.0",
"@types/sanitize-html": "^2.16.1",
"@types/ws": "^8.18.1",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
Expand All @@ -39,10 +40,10 @@
"applesauce-loaders": "^5.0.0",
"applesauce-relay": "^5.0.0",
"carbon-icons-svelte": "^13.8.0",
"isomorphic-dompurify": "^2.35.0",
"marked": "^17.0.1",
"nostr-tools": "^2.19.4",
"rxjs": "^7.8.2",
"sanitize-html": "^2.17.2",
"ws": "^8.18.3"
}
}
3 changes: 2 additions & 1 deletion src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="White Noise" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="canonical" href="https://whitenoise.chat" />
<link rel="alternate" type="text/plain" href="/llms.txt" title="LLM-friendly site overview" />
<link rel="alternate" type="text/plain" href="/llms-full.txt" title="Complete developer documentation" />
<meta name="description" content="A secure and private messenger that's lightning fast, scalable, and identity-free." />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
Expand Down
111 changes: 111 additions & 0 deletions src/lib/blog-post-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from "vitest";
import { createBlogPostCache } from "./blog-post-cache";
import type { BlogPost } from "./nostr";

function createPost(overrides: Partial<BlogPost> = {}): BlogPost {
return {
id: "post-1",
pubkey: "pubkey",
content: "# Hello",
createdAt: 1_700_000_000,
title: "Hello",
image: null,
summary: null,
publishedAt: null,
dTag: "hello",
naddr: "naddr1hello",
tags: [],
...overrides,
};
}

describe("createBlogPostCache", () => {
it("reuses cached blog posts within the ttl", async () => {
const fetchBlogPosts = vi
.fn<() => Promise<BlogPost[]>>()
.mockResolvedValue([createPost({ dTag: "first" })]);
const fetchBlogPostByDTag = vi.fn<(dTag: string) => Promise<BlogPost | null>>();
let now = 10_000;

const cache = createBlogPostCache({
fetchBlogPostByDTag,
fetchBlogPosts,
now: () => now,
ttlMs: 1_000,
});

const first = await cache.fetchBlogPostsCached();
now += 500;
const second = await cache.fetchBlogPostsCached();

expect(first).toEqual(second);
expect(fetchBlogPosts).toHaveBeenCalledTimes(1);
expect(fetchBlogPostByDTag).not.toHaveBeenCalled();
});

it("refreshes cached blog posts after the ttl expires", async () => {
const fetchBlogPosts = vi
.fn<() => Promise<BlogPost[]>>()
.mockResolvedValueOnce([createPost({ dTag: "first" })])
.mockResolvedValueOnce([createPost({ dTag: "second" })]);
const fetchBlogPostByDTag = vi.fn<(dTag: string) => Promise<BlogPost | null>>();
let now = 10_000;

const cache = createBlogPostCache({
fetchBlogPostByDTag,
fetchBlogPosts,
now: () => now,
ttlMs: 1_000,
});

const first = await cache.fetchBlogPostsCached();
now += 1_000;
const second = await cache.fetchBlogPostsCached();

expect(first[0]?.dTag).toBe("first");
expect(second[0]?.dTag).toBe("second");
expect(fetchBlogPosts).toHaveBeenCalledTimes(2);
});

it("serves a single post from the cached posts list when available", async () => {
const fetchBlogPosts = vi
.fn<() => Promise<BlogPost[]>>()
.mockResolvedValue([
createPost({ dTag: "first" }),
createPost({ dTag: "second", id: "post-2", naddr: "naddr1second" }),
]);
const fetchBlogPostByDTag = vi.fn<(dTag: string) => Promise<BlogPost | null>>();

const cache = createBlogPostCache({
fetchBlogPostByDTag,
fetchBlogPosts,
});

await cache.fetchBlogPostsCached();
const post = await cache.fetchBlogPostCached("second");

expect(post?.dTag).toBe("second");
expect(fetchBlogPostByDTag).not.toHaveBeenCalled();
});

it("falls back to direct post fetching when a post is not in cache", async () => {
const fetchedPost = createPost({ dTag: "missing", id: "post-2", naddr: "naddr1missing" });
const fetchBlogPosts = vi
.fn<() => Promise<BlogPost[]>>()
.mockResolvedValue([createPost({ dTag: "first" })]);
const fetchBlogPostByDTag = vi
.fn<(dTag: string) => Promise<BlogPost | null>>()
.mockResolvedValue(fetchedPost);

const cache = createBlogPostCache({
fetchBlogPostByDTag,
fetchBlogPosts,
});

await cache.fetchBlogPostsCached();
const post = await cache.fetchBlogPostCached("missing");

expect(post).toEqual(fetchedPost);
expect(fetchBlogPostByDTag).toHaveBeenCalledWith("missing");
});
});
78 changes: 78 additions & 0 deletions src/lib/blog-post-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { BlogPost } from "./nostr";

const BLOG_POSTS_CACHE_KEY = "blog_posts";
const DEFAULT_CACHE_TTL = 5 * 60 * 1000;

interface CacheEntry {
data: BlogPost[];
timestamp: number;
}

interface CreateBlogPostCacheOptions {
fetchBlogPostByDTag: (dTag: string) => Promise<BlogPost | null>;
fetchBlogPosts: () => Promise<BlogPost[]>;
now?: () => number;
ttlMs?: number;
}

export function createBlogPostCache({
fetchBlogPostByDTag,
fetchBlogPosts,
now = () => Date.now(),
ttlMs = DEFAULT_CACHE_TTL,
}: CreateBlogPostCacheOptions) {
const cache = new Map<string, CacheEntry>();

function getCachedPosts(): BlogPost[] | null {
const cached = cache.get(BLOG_POSTS_CACHE_KEY);

if (!cached) {
return null;
}

if (now() - cached.timestamp >= ttlMs) {
cache.delete(BLOG_POSTS_CACHE_KEY);
return null;
}

return cached.data;
}

function setCachedPosts(posts: BlogPost[]) {
cache.set(BLOG_POSTS_CACHE_KEY, {
data: posts,
timestamp: now(),
});
}

return {
clear() {
cache.clear();
},

async fetchBlogPostCached(dTag: string): Promise<BlogPost | null> {
const cachedPosts = getCachedPosts();

if (cachedPosts) {
const post = cachedPosts.find((entry) => entry.dTag === dTag);
if (post) {
return post;
}
}

return fetchBlogPostByDTag(dTag);
},

async fetchBlogPostsCached(): Promise<BlogPost[]> {
const cachedPosts = getCachedPosts();

if (cachedPosts) {
return cachedPosts;
}

const posts = await fetchBlogPosts();
setCachedPosts(posts);
return posts;
},
};
}
Loading
Loading