diff --git a/fly.toml b/fly.toml index aa11283..99b2880 100644 --- a/fly.toml +++ b/fly.toml @@ -23,6 +23,6 @@ primary_region = "sjc" path = "/healthz" [[vm]] - memory = "256mb" + memory = "512mb" cpu_kind = "shared" cpus = 1 diff --git a/package-lock.json b/package-lock.json index a16d75c..0d9b09b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,10 @@ "gray-matter": "^4.0.3", "hono": "^4.12.2", "ioredis": "^5.6.0", + "katex": "^0.17.0", "marked": "^15.0.0", "marked-footnote": "^1.4.0", + "marked-katex-extension": "^5.1.10", "shiki": "^3.0.0", "zod": "^3.25.76" }, @@ -1132,6 +1134,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/core-js": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", @@ -1467,6 +1478,22 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -1515,6 +1542,16 @@ "marked": ">=7.0.0" } }, + "node_modules/marked-katex-extension": { + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.10.tgz", + "integrity": "sha512-TuqrzguLeXXm6iBaf16leL3+dVmMj8KrBdunMVVzxMS/bwcjtQ0YG0sNytl1j7uUo8yClsXJqBbVjH1yOPurwQ==", + "license": "MIT", + "peerDependencies": { + "katex": ">=0.16 <0.18", + "marked": ">=4 <19" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", diff --git a/package.json b/package.json index 7d85282..ec29dc5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "knowledge:promote": "tsx scripts/promote-knowledge.ts", "knowledge:sync": "tsx scripts/sync-knowledge-atproto.ts", "test:markdown": "tsx --test src/markdown.test.ts", - "test:content": "tsx --test src/about-content.test.ts src/blog-data.test.ts src/knowledge.test.ts src/knowledge-landing.test.tsx", + "test:content": "tsx --test src/about-content.test.ts src/blog-data.test.ts src/profile-data.test.ts src/knowledge.test.ts src/knowledge-landing.test.tsx", "test:charts": "tsx --test src/charts/charts.test.ts src/knowledge-charts/knowledge-charts.test.tsx", "test:worker": "bash scripts/test-content-sync-worker.sh", "test": "pnpm test:markdown && pnpm test:content && pnpm test:charts && pnpm test:worker && pnpm knowledge:check", diff --git a/src/data.ts b/src/data.ts index 99b00ed..e6a262c 100644 --- a/src/data.ts +++ b/src/data.ts @@ -37,6 +37,10 @@ export interface ProfileData { postsCount?: number; } +const DEFAULT_PROFILE_FETCH_TIMEOUT_MS = 3_000; +const profileRequests = new Map>(); +const lastKnownProfiles = new Map(); + function getDid(): string { return process.env.CAMERON_DID || "did:plc:gfrmhdmjvxn2sjedzboeudef"; } @@ -130,32 +134,65 @@ export function parseBlogDocument( }; } +function profileFetchTimeoutMs(): number { + const configured = Number(process.env.PROFILE_FETCH_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_PROFILE_FETCH_TIMEOUT_MS; +} + +async function fetchProfile(did: string, cacheKey: string): Promise { + const fallback = lastKnownProfiles.get(did) ?? null; + try { + const res = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, + { signal: AbortSignal.timeout(profileFetchTimeoutMs()) }, + ); + if (!res.ok) { + await cacheSet(cacheKey, fallback, { life: "seconds" }); + return fallback; + } + const data = await res.json(); + const profile = { + did: data.did, + handle: data.handle, + displayName: data.displayName, + description: data.description, + avatar: data.avatar, + followersCount: data.followersCount, + followsCount: data.followsCount, + postsCount: data.postsCount, + } satisfies ProfileData; + lastKnownProfiles.set(did, profile); + await cacheSet(cacheKey, profile, { life: "minutes" }); + return profile; + } catch (error) { + console.warn( + `[profile] Bluesky profile unavailable; rendering without fresh profile data: ${error instanceof Error ? error.message : String(error)}`, + ); + await cacheSet(cacheKey, fallback, { life: "seconds" }); + return fallback; + } +} + export async function getProfile(): Promise { const did = getDid(); const cacheKey = `profile:${did}`; const cached = await cacheGet(cacheKey); - if (cached !== undefined) return cached as ProfileData | null; - - const res = await fetch( - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` - ); - if (!res.ok) { - await cacheSet(cacheKey, null, { life: "minutes" }); - return null; + if (cached !== undefined) { + const profile = cached as ProfileData | null; + if (profile) lastKnownProfiles.set(did, profile); + return profile; } - const data = await res.json(); - const profile = { - did: data.did, - handle: data.handle, - displayName: data.displayName, - description: data.description, - avatar: data.avatar, - followersCount: data.followersCount, - followsCount: data.followsCount, - postsCount: data.postsCount, - }; - await cacheSet(cacheKey, profile, { life: "minutes" }); - return profile; + + const existing = profileRequests.get(did); + if (existing) return existing; + + const request = fetchProfile(did, cacheKey).finally(() => { + if (profileRequests.get(did) === request) profileRequests.delete(did); + }); + profileRequests.set(did, request); + return request; } export async function listBlogPosts(): Promise { diff --git a/src/profile-data.test.ts b/src/profile-data.test.ts new file mode 100644 index 0000000..8298c2d --- /dev/null +++ b/src/profile-data.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getProfile } from "./data.ts"; + +const originalFetch = globalThis.fetch; +const originalDid = process.env.CAMERON_DID; +const originalTimeout = process.env.PROFILE_FETCH_TIMEOUT_MS; + +function restoreEnvironment(): void { + globalThis.fetch = originalFetch; + if (originalDid === undefined) delete process.env.CAMERON_DID; + else process.env.CAMERON_DID = originalDid; + if (originalTimeout === undefined) delete process.env.PROFILE_FETCH_TIMEOUT_MS; + else process.env.PROFILE_FETCH_TIMEOUT_MS = originalTimeout; +} + +test("shares one in-flight Bluesky profile request across concurrent renders", async () => { + process.env.CAMERON_DID = "did:example:profile-singleflight"; + let fetchCalls = 0; + let releaseFetch: (() => void) | undefined; + globalThis.fetch = (() => { + fetchCalls += 1; + return new Promise((resolve) => { + releaseFetch = () => resolve(Response.json({ + did: "did:example:profile-singleflight", + handle: "cameron.example", + })); + }); + }) as typeof fetch; + + try { + const first = getProfile(); + const second = getProfile(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(fetchCalls, 1); + releaseFetch?.(); + assert.deepEqual(await first, await second); + } finally { + restoreEnvironment(); + } +}); + +test("fails open after the bounded profile timeout", async () => { + process.env.CAMERON_DID = "did:example:profile-timeout"; + process.env.PROFILE_FETCH_TIMEOUT_MS = "20"; + let fetchCalls = 0; + globalThis.fetch = ((_input, init) => { + fetchCalls += 1; + return new Promise((_resolve, reject) => { + const fallback = setTimeout( + () => reject(new Error("profile fetch was not aborted")), + 500, + ); + init?.signal?.addEventListener( + "abort", + () => { + clearTimeout(fallback); + reject(init.signal?.reason ?? new Error("aborted")); + }, + { once: true }, + ); + }); + }) as typeof fetch; + + try { + const startedAt = Date.now(); + assert.equal(await getProfile(), null); + assert.equal(fetchCalls, 1); + assert.ok(Date.now() - startedAt < 250); + } finally { + restoreEnvironment(); + } +});