diff --git a/crons/refresh-sponsors.ts b/crons/refresh-sponsors.ts new file mode 100644 index 00000000..e13e89d2 --- /dev/null +++ b/crons/refresh-sponsors.ts @@ -0,0 +1,36 @@ +// crons/refresh-sponsors.ts +// Daily full re-render of the sponsors cache (force) so avatar/name changes that +// never fire a sponsorship webhook are still picked up. 05:00 UTC = low traffic. +import { defineScheduled } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { loadSponsors } from '../lib/landing/load-sponsors.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { refreshSponsorsCache } from '../lib/sponsors-cache/refresh.ts' +import type { KVStore, R2Store } from '../lib/sponsors-cache/store.ts' + +export const cron = '0 5 * * *' + +export default defineScheduled(async (_controller, env) => { + const e = env as unknown as { + KV: KVStore + STORAGE: R2Store + ASSETS: AssetsFetcher + } + // The scheduled context has no request URL; the ASSETS binding serves by path, + // so the host in the base URL is irrelevant — any absolute URL works. + const base = 'https://napi.rs/' + await refreshSponsorsCache({ + kv: e.KV, + r2: e.STORAGE, + loadFresh: () => loadSponsors({ bypassCache: true }), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, base, p)), + yogaWasm, + resvgWasm, + force: true, + }) +}) diff --git a/docs/superpowers/plans/2026-07-06-sponsors-cache-refresh.md b/docs/superpowers/plans/2026-07-06-sponsors-cache-refresh.md new file mode 100644 index 00000000..97ed155e --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-sponsors-cache-refresh.md @@ -0,0 +1,1458 @@ +# Sponsors Cache + Refresh (webhook + cron) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the napi-rs sponsor list + rendered images always-fresh and cheap to serve by caching them (list JSON in KV, image blobs in R2 keyed via KV) and refreshing on a GitHub Sponsors webhook + a daily cron. + +**Architecture:** A shared `refreshSponsorsCache()` fetches the live sponsor list, renders the 4 image blobs (svg/png × light/dark) once, writes the blobs to R2 under a content-version folder, and flips a KV manifest pointing at them (+ the raw list JSON in KV). A daily cron runs it with `force` (catches avatar/name changes); a signature-verified webhook runs it on sponsorship events (hash-skips no-op changes). The image routes serve bytes straight from R2 via the KV manifest (cold miss → live render + background warm); the landing loaders read the cached list (live fallback). + +**Tech Stack:** Void 0.10.x (Cloudflare Worker), KV binding `c.env.KV`, R2 binding `c.env.STORAGE`, `crons/` + `defineScheduled`, Hono `POST` route, Web Crypto HMAC-SHA256, reuses the existing `lib/sponsors-image/*` render pipeline + `loadSponsors()`. + +## Global Constraints + +Bind every task. Exact values, copied verbatim. + +- **This extends branch `feat/sponsors-image-router` (PR #483).** All work is committed on that branch. +- **Bindings:** enable in `void.json` `inference.bindings`: `kv: true`, `storage: true` (leave `db`, `ai` false). Runtime bindings are `c.env.KV` (KVNamespace) and `c.env.STORAGE` (R2Bucket) — verified present + working in `vp preview`. +- **KV usage:** JSON via `kv.put(key, JSON.stringify(x))` + `JSON.parse(await kv.get(key, {type:'text'}))`. Keys: `sponsors:data` (the `WashedSponsors` list JSON), `sponsors:manifest` (JSON `{version, updatedAt, images}`). +- **R2 usage:** image bytes via `STORAGE.put(key, bytes, { httpMetadata: { contentType } })`, read via `(await STORAGE.get(key))?.arrayBuffer()`, delete via `STORAGE.delete(key|key[])`. R2 object keys: `sponsors//-` (e.g. `sponsors/ab12cd34ef567890/png-dark`). `version` = first 16 hex chars of SHA-256 of the sponsors JSON. +- **Do NOT depend on `@cloudflare/workers-types`.** Define minimal structural `KVStore` / `R2Store` interfaces in `store.ts` and cast `c.env.KV`/`c.env.STORAGE` to them (mirrors the existing `AssetsFetcher` pattern in `lib/sponsors-image/fonts.ts`). +- **Cron:** `crons/refresh-sponsors.ts` — `export const cron = '0 5 * * *'` (daily 05:00 UTC) + `export default defineScheduled(async (controller, env) => …)` from `'void'`. Void auto-configures the CF trigger on deploy. The cron calls refresh with `force: true`. +- **Webhook:** `routes/webhooks/github-sponsors.ts` → `POST /webhooks/github-sponsors`. Read raw body via `await c.req.text()`; verify header `x-hub-signature-256` = `sha256=` (constant-time via `crypto.subtle.verify`). Require header `x-github-event: sponsorship`. On success `c.executionCtx.waitUntil(refresh(force:false))` then return `202`. Bad/missing signature → `401`; secret unset → `503`; other events → `204`. +- **Secret:** add `GITHUB_SPONSORS_WEBHOOK_SECRET: string().secret().optional()` to `env.ts`; read via `import { env } from 'void/env'`. Provisioned via `void secret put` (prod) / `.env.local` (dev). +- **Refresh reuses (do not reimplement):** `loadSponsors()` (add a `{bypassCache?}` param), and from `lib/sponsors-image/`: `capBackers`, `inlineSponsorAvatars` (accepts an optional `fetchImage`), `renderSvg`, `CARD_WIDTH`, `ensureYoga`, `ensureResvg`, `svgToPng`, `loadFonts`, `readAsset`, `parseTheme`. PNG rasterized at `CARD_WIDTH * 2`. SVG content-type `image/svg+xml; charset=utf-8`; PNG `image/png`. +- **wasm imports** (`import x from 'satori/yoga.wasm'` / `'@resvg/resvg-wasm/index_bg.wasm'`) live ONLY in the worker-entry files that need them: `crons/refresh-sponsors.ts`, `routes/webhooks/github-sponsors.ts`, `routes/sponsors.{svg,png}.ts`. `lib/` units receive `WebAssembly.Module` as params (stay Node-testable). +- **Serve `Cache-Control` (both image routes, updated):** `public, s-maxage=600, max-age=600, stale-while-revalidate=86400` (shorter s-maxage than before — origin is now a cheap KV/R2 read, so webhook updates propagate faster). +- **Consumers read cache:** `pages/{en,cn,pt-BR}/index.server.ts` load via `getCachedSponsors(c.env.KV)` (KV-first, live fallback). Image routes serve from R2 via manifest; cold miss → render from `getCachedSponsors(c.env.KV)` (KV-first, live fallback) for this request + `waitUntil(refresh(force:true))` to warm. (Superseded the earlier "live render on cold miss": a bare live fetch degrades to empty on a GitHub outage and that empty image then CDN-caches for `s-maxage`, even when a good KV snapshot was available — Codex PR #483 review.) +- **Preserve `sliver` misspelling** (real data key) everywhere. +- **Test runner:** `corepack yarn vp test run ` (vitest). Add `// @vitest-environment node` to tests using `node:fs`/`WebAssembly`/`crypto`. NOTE: `void/env` reads `globalThis.__env__` (not `process.env`) and THROWS `"Cloudflare env is unavailable"` outside a request — so a test that exercises code reading `env.GITHUB_TOKEN` (e.g. `load-sponsors.test.ts`) must seed `(globalThis as any).__env__ = { GITHUB_TOKEN: 'dummy' }` in `beforeEach` (the token is still required — not weakened); a `GITHUB_TOKEN=dummy` command prefix does NOT work. +- **Never commit** `dist/`, `.env.local`, `.void/`, `node_modules` (gitignored). The build inlines the token into `dist/ssr/wrangler.json` → `rm -rf dist` after any local build. + +## File Structure + +``` +void.json enable kv+storage bindings [Task 1] +env.ts + GITHUB_SPONSORS_WEBHOOK_SECRET [Task 1] +lib/sponsors-cache/ + signature.ts verifyGithubSignature() — Web Crypto HMAC-SHA256 [Task 2] + store.ts KVStore/R2Store types, keys, read*/writeSnapshot [Task 3] + refresh.ts refreshSponsorsCache(), hashSponsors() [Task 6] + *.test.ts +lib/landing/ + load-sponsors.ts + {bypassCache?} param [Task 4] + get-sponsors.ts getCachedSponsors() — KV-first + live fallback [Task 5] +crons/ + refresh-sponsors.ts daily 05:00 UTC, force refresh [Task 7] +routes/webhooks/ + github-sponsors.ts POST — verify sig + waitUntil(refresh) [Task 7] +routes/ + sponsors.svg.ts, sponsors.png.ts (modify) serve from cache + cold warm [Task 8] +pages/{en,cn,pt-BR}/index.server.ts (modify) getCachedSponsors [Task 8] +``` + +--- + +### Task 1: Enable KV + R2 bindings and the webhook secret + +**Files:** + +- Modify: `void.json` (`inference.bindings`) +- Modify: `env.ts` (add secret) + +**Interfaces:** + +- Produces: the `c.env.KV` / `c.env.STORAGE` bindings (provisioned) and `env.GITHUB_SPONSORS_WEBHOOK_SECRET` (typed, optional). + +- [ ] **Step 1: Enable the bindings in `void.json`** + +Find the `inference.bindings` block and set `kv` and `storage` to `true`: + +```json + "inference": { + "bindings": { + "db": false, + "kv": true, + "storage": true, + "ai": false + } + }, +``` + +- [ ] **Step 2: Add the webhook secret to `env.ts`** + +`env.ts` currently declares `GITHUB_TOKEN: string().secret().optional()`. Add the webhook secret in the same `defineEnv({...})` object: + +```ts +GITHUB_SPONSORS_WEBHOOK_SECRET: string().secret().optional(), +``` + +(Keep `.optional()` so a deploy without it doesn't hard-fail; the webhook route returns 503 when it's unset.) + +- [ ] **Step 3: Verify the bindings are generated** + +Run: + +```bash +corepack yarn void prepare && corepack yarn build 2>&1 | tail -3 +node -e "const w=require('./dist/ssr/wrangler.json'); console.log('kv:', JSON.stringify(w.kv_namespaces)); console.log('r2:', JSON.stringify(w.r2_buckets))" +rm -rf dist +``` + +Expected: build succeeds; prints `kv: [{"binding":"KV","id":"local"}]` and `r2: [{"binding":"STORAGE","bucket_name":"default"}]`. + +- [ ] **Step 4: Commit** + +```bash +git add void.json env.ts +git commit -m "feat(sponsors-cache): enable KV + R2 bindings + webhook secret" +``` + +--- + +### Task 2: GitHub webhook signature verification + +**Files:** + +- Create: `lib/sponsors-cache/signature.ts` +- Test: `lib/sponsors-cache/signature.test.ts` + +**Interfaces:** + +- Produces: `function verifyGithubSignature(secret: string, rawBody: string, signatureHeader: string | null | undefined): Promise`. + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-cache/signature.test.ts +import { describe, it, expect } from 'vitest' +import { verifyGithubSignature } from './signature.ts' + +// Build a valid GitHub-style signature header with Web Crypto (same as GitHub does). +async function sign(secret: string, body: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + const mac = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(body), + ) + const hex = [...new Uint8Array(mac)] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + return `sha256=${hex}` +} + +describe('verifyGithubSignature', () => { + const secret = 'topsecret' + const body = '{"action":"created"}' + + it('accepts a correct signature', async () => { + expect( + await verifyGithubSignature(secret, body, await sign(secret, body)), + ).toBe(true) + }) + it('rejects a tampered body', async () => { + expect( + await verifyGithubSignature(secret, body + 'x', await sign(secret, body)), + ).toBe(false) + }) + it('rejects a wrong secret', async () => { + expect( + await verifyGithubSignature('other', body, await sign(secret, body)), + ).toBe(false) + }) + it('rejects missing / malformed headers', async () => { + expect(await verifyGithubSignature(secret, body, undefined)).toBe(false) + expect(await verifyGithubSignature(secret, body, 'sha1=abcd')).toBe(false) + expect(await verifyGithubSignature(secret, body, 'sha256=zz')).toBe(false) + expect(await verifyGithubSignature(secret, body, 'sha256=')).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-cache/signature.test.ts` +Expected: FAIL — cannot resolve `./signature.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// lib/sponsors-cache/signature.ts +// Verify a GitHub webhook X-Hub-Signature-256 header: HMAC-SHA256 of the RAW +// request body, hex, prefixed "sha256=". crypto.subtle.verify is constant-time. + +export async function verifyGithubSignature( + secret: string, + rawBody: string, + signatureHeader: string | null | undefined, +): Promise { + if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false + const sigBytes = hexToBytes(signatureHeader.slice('sha256='.length)) + if (!sigBytes) return false + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ) + return crypto.subtle.verify( + 'HMAC', + key, + sigBytes, + new TextEncoder().encode(rawBody), + ) +} + +function hexToBytes(hex: string): Uint8Array | null { + if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex)) + return null + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-cache/signature.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/sponsors-cache/signature.ts lib/sponsors-cache/signature.test.ts +git commit -m "feat(sponsors-cache): GitHub webhook signature verification" +``` + +--- + +### Task 3: KV + R2 cache store + +**Files:** + +- Create: `lib/sponsors-cache/store.ts` +- Test: `lib/sponsors-cache/store.test.ts` + +**Interfaces:** + +- Consumes: `WashedSponsors` from `../landing/sponsors.ts`. +- Produces: types `ImageFormat = 'svg'|'png'`, `ImageTheme = 'light'|'dark'`, `KVStore`, `R2Store`, `SponsorsManifest`, `RenderedImage`; consts `DATA_KEY`, `MANIFEST_KEY`; `imageSlot(format, theme): string`; `readManifest(kv): Promise`; `readData(kv): Promise`; `readImage(kv, r2, format, theme): Promise<{body: Uint8Array; contentType: string}|null>`; `writeSnapshot(kv, r2, data, version, images: RenderedImage[]): Promise`. + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-cache/store.test.ts +import { describe, it, expect } from 'vitest' +import { + readManifest, + readData, + readImage, + writeSnapshot, + imageSlot, + DATA_KEY, + MANIFEST_KEY, + type KVStore, + type R2Store, + type RenderedImage, +} from './store.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +function fakeKV(): KVStore & { store: Map } { + const store = new Map() + return { + store, + async get(key: string) { + return store.has(key) ? store.get(key)! : null + }, + async put(key: string, value: string) { + store.set(key, value) + }, + } +} +function fakeR2(): R2Store & { store: Map } { + const store = new Map() + return { + store, + async get(key: string) { + if (!store.has(key)) return null + const bytes = store.get(key)! + return { + async arrayBuffer() { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer + }, + } + }, + async put(key: string, value: ArrayBuffer | ArrayBufferView) { + store.set( + key, + value instanceof ArrayBuffer + ? new Uint8Array(value) + : new Uint8Array((value as ArrayBufferView).buffer), + ) + }, + async delete(key: string | string[]) { + for (const k of Array.isArray(key) ? key : [key]) store.delete(k) + }, + } +} +function sample(): WashedSponsors { + return { + specialThanks: [{ name: 'A', img: 'x', url: 'y' }], + platinum: [], + gold: [], + sliver: [], + backers: [], + } +} +function images(tag: string): RenderedImage[] { + return [ + { + format: 'svg', + theme: 'light', + body: new TextEncoder().encode(`${tag}L`), + contentType: 'image/svg+xml; charset=utf-8', + }, + { + format: 'svg', + theme: 'dark', + body: new TextEncoder().encode(`${tag}D`), + contentType: 'image/svg+xml; charset=utf-8', + }, + { + format: 'png', + theme: 'light', + body: new Uint8Array([1, 2, 3]), + contentType: 'image/png', + }, + { + format: 'png', + theme: 'dark', + body: new Uint8Array([4, 5, 6]), + contentType: 'image/png', + }, + ] +} + +describe('store', () => { + it('writeSnapshot persists data + manifest + 4 R2 objects; readers see them', async () => { + const kv = fakeKV(), + r2 = fakeR2() + await writeSnapshot(kv, r2, sample(), 'v1', images('a')) + expect(kv.store.has(DATA_KEY)).toBe(true) + expect(kv.store.has(MANIFEST_KEY)).toBe(true) + expect(r2.store.size).toBe(4) + + const data = await readData(kv) + expect(data?.specialThanks[0].name).toBe('A') + + const manifest = await readManifest(kv) + expect(manifest?.version).toBe('v1') + expect(manifest?.images[imageSlot('png', 'dark')].key).toBe( + 'sponsors/v1/png-dark', + ) + + const img = await readImage(kv, r2, 'png', 'dark') + expect([...img!.body]).toEqual([4, 5, 6]) + expect(img!.contentType).toBe('image/png') + }) + + it('a new snapshot version deletes the previous version R2 objects', async () => { + const kv = fakeKV(), + r2 = fakeR2() + await writeSnapshot(kv, r2, sample(), 'v1', images('a')) + await writeSnapshot(kv, r2, sample(), 'v2', images('b')) + expect(r2.store.size).toBe(4) // only v2 objects remain + expect( + [...r2.store.keys()].every((k) => k.startsWith('sponsors/v2/')), + ).toBe(true) + expect((await readManifest(kv))?.version).toBe('v2') + }) + + it('readImage returns null when nothing is cached', async () => { + expect(await readImage(fakeKV(), fakeR2(), 'svg', 'light')).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-cache/store.test.ts` +Expected: FAIL — cannot resolve `./store.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// lib/sponsors-cache/store.ts +// KV + R2 cache for the sponsor list and rendered images. The list JSON and a +// manifest (mapping format:theme -> R2 object key) live in KV; the image bytes +// live in R2 under a per-version folder. writeSnapshot flips the manifest last +// (atomic pointer swap) and best-effort deletes the previous version's objects. + +import type { WashedSponsors } from '../landing/sponsors.ts' + +export type ImageFormat = 'svg' | 'png' +export type ImageTheme = 'light' | 'dark' + +// Minimal structural bindings (avoids depending on @cloudflare/workers-types). +export interface KVStore { + get(key: string, opts?: { type?: 'text' }): Promise + put( + key: string, + value: string, + opts?: { expirationTtl?: number }, + ): Promise +} +export interface R2Store { + get(key: string): Promise<{ arrayBuffer(): Promise } | null> + put( + key: string, + value: ArrayBuffer | ArrayBufferView, + opts?: { httpMetadata?: { contentType?: string } }, + ): Promise + delete(key: string | string[]): Promise +} + +export interface ImageEntry { + key: string + contentType: string +} +export interface SponsorsManifest { + version: string + updatedAt: string + images: Record +} +export interface RenderedImage { + format: ImageFormat + theme: ImageTheme + body: Uint8Array + contentType: string +} + +export const DATA_KEY = 'sponsors:data' +export const MANIFEST_KEY = 'sponsors:manifest' + +export function imageSlot(format: ImageFormat, theme: ImageTheme): string { + return `${format}:${theme}` +} + +export async function readManifest( + kv: KVStore, +): Promise { + const raw = await kv.get(MANIFEST_KEY, { type: 'text' }) + return raw ? (JSON.parse(raw) as SponsorsManifest) : null +} + +export async function readData(kv: KVStore): Promise { + const raw = await kv.get(DATA_KEY, { type: 'text' }) + return raw ? (JSON.parse(raw) as WashedSponsors) : null +} + +export async function readImage( + kv: KVStore, + r2: R2Store, + format: ImageFormat, + theme: ImageTheme, +): Promise<{ body: Uint8Array; contentType: string } | null> { + const manifest = await readManifest(kv) + const entry = manifest?.images[imageSlot(format, theme)] + if (!entry) return null + const obj = await r2.get(entry.key) + if (!obj) return null + return { + body: new Uint8Array(await obj.arrayBuffer()), + contentType: entry.contentType, + } +} + +export async function writeSnapshot( + kv: KVStore, + r2: R2Store, + data: WashedSponsors, + version: string, + images: RenderedImage[], +): Promise { + const previous = await readManifest(kv) + const manifest: SponsorsManifest = { + version, + updatedAt: new Date().toISOString(), + images: {}, + } + for (const img of images) { + const key = `sponsors/${version}/${img.format}-${img.theme}` + await r2.put(key, img.body, { + httpMetadata: { contentType: img.contentType }, + }) + manifest.images[imageSlot(img.format, img.theme)] = { + key, + contentType: img.contentType, + } + } + await kv.put(DATA_KEY, JSON.stringify(data)) + await kv.put(MANIFEST_KEY, JSON.stringify(manifest)) + if (previous && previous.version !== version) { + const oldKeys = Object.values(previous.images).map((e) => e.key) + if (oldKeys.length) await r2.delete(oldKeys).catch(() => {}) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-cache/store.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/sponsors-cache/store.ts lib/sponsors-cache/store.test.ts +git commit -m "feat(sponsors-cache): KV manifest + R2 blob store" +``` + +--- + +### Task 4: `loadSponsors` cache-bypass option + +**Files:** + +- Modify: `lib/landing/load-sponsors.ts:152-155` +- Test: `lib/landing/load-sponsors.test.ts` + +**Interfaces:** + +- Produces: `loadSponsors(options?: { bypassCache?: boolean }): Promise` — `bypassCache: true` skips the 10-min in-isolate cache and always refetches. Existing no-arg callers are unaffected. + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/landing/load-sponsors.test.ts +// Run with a token so the loader actually fetches: GITHUB_TOKEN=dummy vp test run +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { loadSponsors } from './load-sponsors.ts' + +const EMPTY_PAYLOAD = { + data: { + org: { + sponsorshipsAsMaintainer: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + special: null, + }, +} + +describe('loadSponsors bypassCache', () => { + let calls: number + beforeEach(() => { + calls = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + calls += 1 + return new Response(JSON.stringify(EMPTY_PAYLOAD), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }), + ) + }) + afterEach(() => vi.unstubAllGlobals()) + + it('caches by default and bypasses when asked', async () => { + await loadSponsors() // fetch #1, populates the in-isolate cache + expect(calls).toBe(1) + await loadSponsors() // served from cache, no fetch + expect(calls).toBe(1) + await loadSponsors({ bypassCache: true }) // forces fetch #2 + expect(calls).toBe(2) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `GITHUB_TOKEN=dummy corepack yarn vp test run lib/landing/load-sponsors.test.ts` +Expected: FAIL — the third assertion fails (`calls` is 1, not 2) because `loadSponsors` ignores the option today. + +- [ ] **Step 3: Modify the implementation** + +In `lib/landing/load-sponsors.ts`, change the signature + the cache-read guard (currently lines 152-155): + +```ts +export async function loadSponsors(options?: { bypassCache?: boolean }): Promise { + if (!options?.bypassCache && cache && cache.expiresAt > Date.now()) { + return cache.value + } + // ...rest of the function unchanged... +``` + +Change nothing else. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `GITHUB_TOKEN=dummy corepack yarn vp test run lib/landing/load-sponsors.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/landing/load-sponsors.ts lib/landing/load-sponsors.test.ts +git commit -m "feat(sponsors-cache): loadSponsors bypassCache option" +``` + +--- + +### Task 5: KV-first cached sponsor list getter + +**Files:** + +- Create: `lib/landing/get-sponsors.ts` +- Test: `lib/landing/get-sponsors.test.ts` + +**Interfaces:** + +- Consumes: `WashedSponsors` (`./sponsors.ts`), `readData`/`KVStore` (`../sponsors-cache/store.ts`), `loadSponsors` (`./load-sponsors.ts`). +- Produces: `getCachedSponsors(kv: KVStore | undefined, loadLive?: () => Promise): Promise` — returns the KV `sponsors:data` snapshot if present, else calls `loadLive` (defaults to `loadSponsors`). + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/landing/get-sponsors.test.ts +import { describe, it, expect, vi } from 'vitest' +import { getCachedSponsors } from './get-sponsors.ts' +import { DATA_KEY, type KVStore } from '../sponsors-cache/store.ts' +import type { WashedSponsors } from './sponsors.ts' + +const cached: WashedSponsors = { + specialThanks: [], + platinum: [{ name: 'KV', img: 'i', url: 'u' }], + gold: [], + sliver: [], + backers: [], +} +const live: WashedSponsors = { + specialThanks: [], + platinum: [{ name: 'LIVE', img: 'i', url: 'u' }], + gold: [], + sliver: [], + backers: [], +} + +function kvWith(entries: Record): KVStore { + return { + async get(k: string) { + return entries[k] ?? null + }, + async put() {}, + } +} + +describe('getCachedSponsors', () => { + it('returns the KV snapshot without calling live when present', async () => { + const loadLive = vi.fn(async () => live) + const out = await getCachedSponsors( + kvWith({ [DATA_KEY]: JSON.stringify(cached) }), + loadLive, + ) + expect(out.platinum[0].name).toBe('KV') + expect(loadLive).not.toHaveBeenCalled() + }) + it('falls back to live when KV is empty', async () => { + const loadLive = vi.fn(async () => live) + const out = await getCachedSponsors(kvWith({}), loadLive) + expect(out.platinum[0].name).toBe('LIVE') + expect(loadLive).toHaveBeenCalledOnce() + }) + it('falls back to live when KV binding is missing', async () => { + const loadLive = vi.fn(async () => live) + const out = await getCachedSponsors(undefined, loadLive) + expect(out.platinum[0].name).toBe('LIVE') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/landing/get-sponsors.test.ts` +Expected: FAIL — cannot resolve `./get-sponsors.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// lib/landing/get-sponsors.ts +// KV-first sponsor list for consumers (landing loaders + the image cold path): +// return the cron/webhook-maintained snapshot when present, else the live GitHub +// fetch so the site still works before the first refresh. + +import type { WashedSponsors } from './sponsors.ts' +import { readData, type KVStore } from '../sponsors-cache/store.ts' +import { loadSponsors } from './load-sponsors.ts' + +export async function getCachedSponsors( + kv: KVStore | undefined, + loadLive: () => Promise = loadSponsors, +): Promise { + if (kv) { + const cached = await readData(kv) + if (cached) return cached + } + return loadLive() +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/landing/get-sponsors.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/landing/get-sponsors.ts lib/landing/get-sponsors.test.ts +git commit -m "feat(sponsors-cache): KV-first getCachedSponsors" +``` + +--- + +### Task 6: Refresh pipeline (render all 4 + write snapshot) + +**Files:** + +- Create: `lib/sponsors-cache/refresh.ts` +- Test: `lib/sponsors-cache/refresh.test.ts` + +**Interfaces:** + +- Consumes: `capBackers`, `renderSvg`, `CARD_WIDTH`, `ensureYoga` (`../sponsors-image/card.ts`); `ensureResvg`, `svgToPng` (`../sponsors-image/resvg.ts`); `inlineSponsorAvatars`, `ImageFetcher` (`../sponsors-image/avatars.ts`); `SatoriFont` (`../sponsors-image/fonts.ts`); `WashedSponsors` (`../landing/sponsors.ts`); `writeSnapshot`, `readManifest`, `KVStore`, `R2Store`, `RenderedImage` (`./store.ts`). +- Produces: `hashSponsors(data: WashedSponsors): Promise`; `RefreshDeps`; `RefreshResult = { version: string; changed: boolean; imageCount: number }`; `refreshSponsorsCache(deps: RefreshDeps): Promise`. + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-cache/refresh.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { + refreshSponsorsCache, + hashSponsors, + type RefreshDeps, +} from './refresh.ts' +import { + readManifest, + readImage, + readData, + type KVStore, + type R2Store, +} from './store.ts' +import { ensureYoga } from '../sponsors-image/card.ts' +import { ensureResvg } from '../sponsors-image/resvg.ts' +import type { SatoriFont } from '../sponsors-image/fonts.ts' +import type { ImageFetcher } from '../sponsors-image/avatars.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +const yogaMod = new WebAssembly.Module( + readFileSync('node_modules/satori/yoga.wasm'), +) +const resvgMod = new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), +) +const onePng = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + ), + (c) => c.charCodeAt(0), +) + +function fonts(): SatoriFont[] { + const toAB = (b: Buffer): ArrayBuffer => + b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer + return [ + { + name: 'Manrope', + data: toAB(readFileSync('public/fonts/Manrope_700Bold.ttf')), + weight: 700, + style: 'normal', + }, + { + name: 'Manrope', + data: toAB(readFileSync('public/fonts/Manrope_500Medium.ttf')), + weight: 500, + style: 'normal', + }, + ] +} +function fakeKV(): KVStore & { store: Map } { + const store = new Map() + return { + store, + async get(k) { + return store.get(k) ?? null + }, + async put(k, v) { + store.set(k, v) + }, + } +} +function fakeR2(): R2Store & { store: Map } { + const store = new Map() + return { + store, + async get(k) { + const b = store.get(k) + return b + ? { + async arrayBuffer() { + return b.buffer.slice( + b.byteOffset, + b.byteOffset + b.byteLength, + ) as ArrayBuffer + }, + } + : null + }, + async put(k, v) { + store.set( + k, + v instanceof ArrayBuffer + ? new Uint8Array(v) + : new Uint8Array((v as ArrayBufferView).buffer), + ) + }, + async delete(k) { + for (const x of Array.isArray(k) ? k : [k]) store.delete(x) + }, + } +} +const fetchImage: ImageFetcher = async () => + new Response(onePng, { + status: 200, + headers: { 'content-type': 'image/png' }, + }) +const sponsors: WashedSponsors = { + specialThanks: [], + platinum: [ + { name: 'A', img: 'https://x/a.png', url: 'https://github.com/a' }, + ], + gold: [], + sliver: [], + backers: [], +} + +function deps( + kv: KVStore, + r2: R2Store, + over: Partial = {}, +): RefreshDeps { + return { + kv, + r2, + loadFresh: async () => sponsors, + loadFonts: async () => fonts(), + fetchImage, + yogaWasm: yogaMod, + resvgWasm: resvgMod, + ...over, + } +} + +beforeAll(async () => { + await ensureYoga(yogaMod) + await ensureResvg(resvgMod) +}) + +describe('refreshSponsorsCache', () => { + it('renders 4 images, writes data + manifest + R2, and reports changed', async () => { + const kv = fakeKV(), + r2 = fakeR2() + const result = await refreshSponsorsCache(deps(kv, r2)) + expect(result.changed).toBe(true) + expect(result.imageCount).toBe(4) + expect(r2.store.size).toBe(4) + expect((await readData(kv))?.platinum[0].name).toBe('A') + const png = await readImage(kv, r2, 'png', 'dark') + expect([...png!.body.slice(0, 8)]).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]) + const svg = await readImage(kv, r2, 'svg', 'light') + expect(new TextDecoder().decode(svg!.body).startsWith(' { + const kv = fakeKV(), + r2 = fakeR2() + await refreshSponsorsCache(deps(kv, r2)) + const second = await refreshSponsorsCache(deps(kv, r2, { force: false })) + expect(second.changed).toBe(false) + expect(second.imageCount).toBe(0) + }) + + it('re-renders unchanged data when force is true', async () => { + const kv = fakeKV(), + r2 = fakeR2() + await refreshSponsorsCache(deps(kv, r2)) + const forced = await refreshSponsorsCache(deps(kv, r2, { force: true })) + expect(forced.changed).toBe(true) + expect(forced.imageCount).toBe(4) + }) + + it('hashSponsors is stable for equal data and differs for different data', async () => { + const other: WashedSponsors = { + ...sponsors, + gold: [{ name: 'G', img: 'g', url: 'u' }], + } + expect(await hashSponsors(sponsors)).toBe(await hashSponsors(sponsors)) + expect(await hashSponsors(sponsors)).not.toBe(await hashSponsors(other)) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-cache/refresh.test.ts` +Expected: FAIL — cannot resolve `./refresh.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// lib/sponsors-cache/refresh.ts +// Fetch the live sponsor list, render the 4 image blobs (svg/png x light/dark) +// once, and publish a new cache snapshot (KV data + manifest, R2 blobs). Skips +// re-rendering when the data hash matches the current manifest (unless force). +// The caller injects the wasm modules, the fresh loader, fonts, and (optionally) +// an avatar fetcher, so this is fully unit-testable in Node. + +import type { WashedSponsors } from '../landing/sponsors.ts' +import { + capBackers, + renderSvg, + CARD_WIDTH, + ensureYoga, +} from '../sponsors-image/card.ts' +import { ensureResvg, svgToPng } from '../sponsors-image/resvg.ts' +import { + inlineSponsorAvatars, + type ImageFetcher, +} from '../sponsors-image/avatars.ts' +import type { SatoriFont } from '../sponsors-image/fonts.ts' +import { + writeSnapshot, + readManifest, + type KVStore, + type R2Store, + type RenderedImage, +} from './store.ts' + +const PNG_SCALE = 2 +const THEMES = ['light', 'dark'] as const + +export interface RefreshDeps { + kv: KVStore + r2: R2Store + loadFresh: () => Promise + loadFonts: () => Promise + yogaWasm: WebAssembly.Module + resvgWasm: WebAssembly.Module + fetchImage?: ImageFetcher + force?: boolean +} + +export interface RefreshResult { + version: string + changed: boolean + imageCount: number +} + +// Short content hash (first 16 hex of SHA-256) used as the cache version + the +// R2 folder. Sponsorship changes flip it; a sponsor swapping only their avatar +// image keeps the same avatarUrl and thus the same hash (that is what the daily +// force refresh is for). +export async function hashSponsors(data: WashedSponsors): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(data)) + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest).slice(0, 8)] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +export async function refreshSponsorsCache( + deps: RefreshDeps, +): Promise { + const data = await deps.loadFresh() + const version = await hashSponsors(data) + + if (!deps.force) { + const current = await readManifest(deps.kv) + if (current?.version === version) { + return { version, changed: false, imageCount: 0 } + } + } + + await ensureYoga(deps.yogaWasm) + await ensureResvg(deps.resvgWasm) + const fonts = await deps.loadFonts() + const inlined = await inlineSponsorAvatars(capBackers(data), deps.fetchImage) + + const images: RenderedImage[] = [] + for (const theme of THEMES) { + const svg = await renderSvg(inlined, theme, fonts) + images.push({ + format: 'svg', + theme, + body: new TextEncoder().encode(svg), + contentType: 'image/svg+xml; charset=utf-8', + }) + const png = svgToPng(svg, CARD_WIDTH * PNG_SCALE) + images.push({ format: 'png', theme, body: png, contentType: 'image/png' }) + } + + await writeSnapshot(deps.kv, deps.r2, data, version, images) + return { version, changed: true, imageCount: images.length } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-cache/refresh.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/sponsors-cache/refresh.ts lib/sponsors-cache/refresh.test.ts +git commit -m "feat(sponsors-cache): refresh pipeline (render 4 + snapshot, hash-skip)" +``` + +--- + +### Task 7: Cron + webhook refresh triggers + +**Files:** + +- Create: `crons/refresh-sponsors.ts` +- Create: `routes/webhooks/github-sponsors.ts` + +**Interfaces:** + +- Consumes: `refreshSponsorsCache`/`RefreshDeps` (`../lib/sponsors-cache/refresh.ts` / `../../lib/...`), `verifyGithubSignature` (`../../lib/sponsors-cache/signature.ts`), `loadSponsors` (bypassCache), `loadFonts`/`readAsset`/`AssetsFetcher` (`lib/sponsors-image/fonts.ts`), `KVStore`/`R2Store` (`lib/sponsors-cache/store.ts`), `defineScheduled`/`defineHandler` (`void`), `env` (`void/env`). +- Produces: the daily cron job and `POST /webhooks/github-sponsors`. + +This task is verified by build + a workerd `vp preview` smoke test (cron + webhook wiring can't be meaningfully unit-tested; the refresh logic is already covered by Task 6, and signature verification by Task 2). + +- [ ] **Step 1: Write the cron job** + +```ts +// crons/refresh-sponsors.ts +// Daily full re-render of the sponsors cache (force) so avatar/name changes that +// never fire a sponsorship webhook are still picked up. 05:00 UTC = low traffic. +import { defineScheduled } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { loadSponsors } from '../lib/landing/load-sponsors.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { refreshSponsorsCache } from '../lib/sponsors-cache/refresh.ts' +import type { KVStore, R2Store } from '../lib/sponsors-cache/store.ts' + +export const cron = '0 5 * * *' + +export default defineScheduled(async (_controller, env) => { + const e = env as unknown as { + KV: KVStore + STORAGE: R2Store + ASSETS: AssetsFetcher + } + // The scheduled context has no request URL; the ASSETS binding serves by path, + // so the host in the base URL is irrelevant — any absolute URL works. + const base = 'https://napi.rs/' + await refreshSponsorsCache({ + kv: e.KV, + r2: e.STORAGE, + loadFresh: () => loadSponsors({ bypassCache: true }), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, base, p)), + yogaWasm, + resvgWasm, + force: true, + }) +}) +``` + +- [ ] **Step 2: Write the webhook route** + +```ts +// routes/webhooks/github-sponsors.ts +// POST /webhooks/github-sponsors — GitHub Sponsors webhook receiver. Verifies the +// HMAC signature over the raw body, then refreshes the cache in the background so +// we return 2xx well within GitHub's 10s deadline. +import { defineHandler } from 'void' +import { env } from 'void/env' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { verifyGithubSignature } from '../../lib/sponsors-cache/signature.ts' +import { loadSponsors } from '../../lib/landing/load-sponsors.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../../lib/sponsors-image/fonts.ts' +import { refreshSponsorsCache } from '../../lib/sponsors-cache/refresh.ts' +import type { KVStore, R2Store } from '../../lib/sponsors-cache/store.ts' + +export const POST = defineHandler(async (c) => { + const secret = env.GITHUB_SPONSORS_WEBHOOK_SECRET + if (!secret) return c.text('webhook not configured', 503) + + const raw = await c.req.text() + const valid = await verifyGithubSignature( + secret, + raw, + c.req.header('x-hub-signature-256'), + ) + if (!valid) return c.text('invalid signature', 401) + + if (c.req.header('x-github-event') !== 'sponsorship') return c.body(null, 204) + + const e = c.env as unknown as { + KV: KVStore + STORAGE: R2Store + ASSETS: AssetsFetcher + } + c.executionCtx.waitUntil( + refreshSponsorsCache({ + kv: e.KV, + r2: e.STORAGE, + loadFresh: () => loadSponsors({ bypassCache: true }), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)), + yogaWasm, + resvgWasm, + force: false, + }), + ) + return c.body(null, 202) +}) +``` + +- [ ] **Step 3: Build and register the cron + route** + +Run: + +```bash +corepack yarn void prepare +corepack yarn build 2>&1 | tail -3 +node -e "const w=require('./dist/ssr/wrangler.json'); console.log('crons:', JSON.stringify(w.triggers))" +grep -q '"/webhooks/github-sponsors"' .void/routes.d.ts && echo 'webhook route registered' || echo 'MISSING webhook route' +``` + +Expected: build passes; `crons:` shows the `0 5 * * *` schedule (e.g. `{"crons":["0 5 * * *"]}`); "webhook route registered". + +- [ ] **Step 4: Smoke-test the webhook in workerd (valid + invalid signature)** + +Set a dev secret so the preview worker can read it, then exercise the route: + +```bash +# Ensure a webhook secret is available to the preview worker (gitignored .env.local) +grep -q GITHUB_SPONSORS_WEBHOOK_SECRET .env.local || echo 'GITHUB_SPONSORS_WEBHOOK_SECRET=devsecret' >> .env.local +corepack yarn build >/dev/null 2>&1 +(corepack yarn preview > /tmp/hook-preview.log 2>&1 &) ; sleep 7 + +BODY='{"action":"created","sponsorship":{"sponsor":{"login":"x"}}}' +SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac devsecret | sed 's/^.*= //')" + +echo "--- valid sig + sponsorship event (expect 202) ---" +curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:4173/webhooks/github-sponsors" \ + -H "content-type: application/json" -H "x-github-event: sponsorship" -H "x-hub-signature-256: $SIG" -d "$BODY" +echo "--- bad sig (expect 401) ---" +curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:4173/webhooks/github-sponsors" \ + -H "content-type: application/json" -H "x-github-event: sponsorship" -H "x-hub-signature-256: sha256=deadbeef" -d "$BODY" +echo "--- wrong event (expect 204) ---" +curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:4173/webhooks/github-sponsors" \ + -H "content-type: application/json" -H "x-github-event: ping" -H "x-hub-signature-256: $SIG" -d "$BODY" + +sleep 3 # let the background refresh run +pkill -f "vite preview" 2>/dev/null; pkill -f preview 2>/dev/null +grep -iE "error|exception" /tmp/hook-preview.log | head +rm -rf dist +``` + +Expected: `202`, then `401`, then `204`. No errors in the log. (The valid call triggers a real background refresh against GitHub using the token in `.env.local` — it populates the local KV/R2, exercised further in Task 8.) + +- [ ] **Step 5: Commit** + +```bash +git add crons/refresh-sponsors.ts routes/webhooks/github-sponsors.ts +git commit -m "feat(sponsors-cache): daily cron + GitHub Sponsors webhook refresh" +``` + +--- + +### Task 8: Serve images from cache + landing reads cached list + +**Files:** + +- Modify: `routes/sponsors.svg.ts`, `routes/sponsors.png.ts` (full rewrite of each — shown below) +- Modify: `pages/en/index.server.ts`, `pages/cn/index.server.ts`, `pages/pt-BR/index.server.ts` + +**Interfaces:** + +- Consumes: `readImage`/`KVStore`/`R2Store` (`../lib/sponsors-cache/store.ts`), `refreshSponsorsCache` (`../lib/sponsors-cache/refresh.ts`), `getCachedSponsors` (`../lib/landing/get-sponsors.ts` / `../../lib/...`), plus the existing render pieces. +- Produces: cache-first `/sponsors.{svg,png}` and cache-first landing loaders. + +Verified by build + workerd smoke: after a refresh populates KV/R2, the image routes return the cached bytes; a cold KV returns a live render. + +- [ ] **Step 1: Rewrite `routes/sponsors.png.ts`** + +```ts +// routes/sponsors.png.ts +// GET /sponsors.png — serve the cached PNG from R2 (via the KV manifest). On a +// cold cache, render this one live and kick off a full background refresh to warm +// the cache for subsequent requests. ?theme=light|dark (default light). +import { defineHandler } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { loadSponsors } from '../lib/landing/load-sponsors.ts' +import { parseTheme } from '../lib/sponsors-image/theme.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { ensureYoga } from '../lib/sponsors-image/card.ts' +import { ensureResvg } from '../lib/sponsors-image/resvg.ts' +import { renderSponsorsImage } from '../lib/sponsors-image/render.ts' +import { + readImage, + type KVStore, + type R2Store, +} from '../lib/sponsors-cache/store.ts' +import { refreshSponsorsCache } from '../lib/sponsors-cache/refresh.ts' + +const CACHE_CONTROL = + 'public, s-maxage=600, max-age=600, stale-while-revalidate=86400' + +export const GET = defineHandler(async (c) => { + const theme = parseTheme(c.req.query('theme')) + const e = c.env as unknown as { + KV?: KVStore + STORAGE?: R2Store + ASSETS: AssetsFetcher + } + + if (e.KV && e.STORAGE) { + const cached = await readImage(e.KV, e.STORAGE, 'png', theme) + if (cached) { + return new Response(cached.body, { + headers: { + 'Content-Type': cached.contentType, + 'Cache-Control': CACHE_CONTROL, + }, + }) + } + // Cold cache: warm all 4 blobs in the background for next time. + c.executionCtx.waitUntil( + refreshSponsorsCache({ + kv: e.KV, + r2: e.STORAGE, + loadFresh: () => loadSponsors({ bypassCache: true }), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)), + yogaWasm, + resvgWasm, + force: true, + }), + ) + } + + await ensureYoga(yogaWasm) + await ensureResvg(resvgWasm) + const sponsors = await loadSponsors() + const fonts = await loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)) + const { body, contentType } = await renderSponsorsImage({ + format: 'png', + theme, + sponsors, + fonts, + }) + return new Response(body, { + headers: { 'Content-Type': contentType, 'Cache-Control': CACHE_CONTROL }, + }) +}) +``` + +- [ ] **Step 2: Rewrite `routes/sponsors.svg.ts`** + +```ts +// routes/sponsors.svg.ts +// GET /sponsors.svg — serve the cached SVG from R2 (via the KV manifest); cold +// cache → render live + background warm. ?theme=light|dark (default light). +import { defineHandler } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { loadSponsors } from '../lib/landing/load-sponsors.ts' +import { parseTheme } from '../lib/sponsors-image/theme.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { ensureYoga } from '../lib/sponsors-image/card.ts' +import { renderSponsorsImage } from '../lib/sponsors-image/render.ts' +import { + readImage, + type KVStore, + type R2Store, +} from '../lib/sponsors-cache/store.ts' +import { refreshSponsorsCache } from '../lib/sponsors-cache/refresh.ts' + +const CACHE_CONTROL = + 'public, s-maxage=600, max-age=600, stale-while-revalidate=86400' + +export const GET = defineHandler(async (c) => { + const theme = parseTheme(c.req.query('theme')) + const e = c.env as unknown as { + KV?: KVStore + STORAGE?: R2Store + ASSETS: AssetsFetcher + } + + if (e.KV && e.STORAGE) { + const cached = await readImage(e.KV, e.STORAGE, 'svg', theme) + if (cached) { + return new Response(cached.body, { + headers: { + 'Content-Type': cached.contentType, + 'Cache-Control': CACHE_CONTROL, + }, + }) + } + c.executionCtx.waitUntil( + refreshSponsorsCache({ + kv: e.KV, + r2: e.STORAGE, + loadFresh: () => loadSponsors({ bypassCache: true }), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)), + yogaWasm, + resvgWasm, + force: true, + }), + ) + } + + await ensureYoga(yogaWasm) + const sponsors = await loadSponsors() + const fonts = await loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)) + const { body, contentType } = await renderSponsorsImage({ + format: 'svg', + theme, + sponsors, + fonts, + }) + return new Response(body, { + headers: { 'Content-Type': contentType, 'Cache-Control': CACHE_CONTROL }, + }) +}) +``` + +- [ ] **Step 3: Point the landing loaders at the cached list** + +Each of `pages/en/index.server.ts`, `pages/cn/index.server.ts`, `pages/pt-BR/index.server.ts` currently has a loader like: + +```ts +export const loader = defineHandler(async () => ({ + sponsors: await loadSponsors(), +})) +``` + +For EACH file: (a) replace the `loadSponsors` import line + +```ts +import { loadSponsors } from '../../lib/landing/load-sponsors.ts' +``` + +with + +```ts +import { getCachedSponsors } from '../../lib/landing/get-sponsors.ts' +import type { KVStore } from '../../lib/sponsors-cache/store.ts' +``` + +and (b) change the loader body to read the KV binding off the context: + +```ts +export const loader = defineHandler(async (c) => ({ + sponsors: await getCachedSponsors((c.env as unknown as { KV?: KVStore }).KV), +})) +``` + +Keep everything else in each file (the `head`, `Props`, `prerender`, etc.) exactly as-is. Note the relative import depth is `../../lib/...` from `pages//`. + +- [ ] **Step 4: Build + smoke the full cache path in workerd** + +Run: + +```bash +corepack yarn build 2>&1 | tail -3 +(corepack yarn preview > /tmp/cache-preview.log 2>&1 &) ; sleep 7 + +echo "--- cold PNG (live render, 200) ---" +curl -s -o /tmp/cold.png -m 40 -w "http %{http_code} %{content_type}\n" "http://localhost:4173/sponsors.png" +sleep 4 # let the background warm refresh finish + +echo "--- warm PNG (from R2 cache, 200 image/png, valid magic) ---" +curl -s -o /tmp/warm.png -m 40 -w "http %{http_code} %{content_type} %{size_download}B\n" "http://localhost:4173/sponsors.png" +xxd -l 8 /tmp/warm.png +echo "--- warm SVG dark ---" +curl -s -o /tmp/warm.svg -m 40 -w "http %{http_code} %{content_type}\n" "http://localhost:4173/sponsors.svg?theme=dark" +head -c 40 /tmp/warm.svg; echo +echo "--- landing renders (200, has sponsor markup) ---" +curl -s -m 40 "http://localhost:4173/" | grep -c -i "sponsor" | sed 's/^/sponsor mentions: /' + +pkill -f "vite preview" 2>/dev/null; pkill -f preview 2>/dev/null +grep -iE "error|exception" /tmp/cache-preview.log | head +rm -rf dist +``` + +Expected: cold PNG `200 image/png`; warm PNG `200 image/png` with magic `8950 4e47 0d0a 1a0a` and size > 2 KB; warm SVG `200 image/svg+xml…` starting `&1 | tail -3 +git add routes/sponsors.svg.ts routes/sponsors.png.ts pages/en/index.server.ts pages/cn/index.server.ts pages/pt-BR/index.server.ts +git commit -m "feat(sponsors-cache): serve images from cache; landing reads cached list" +``` + +Expected: full suite green. + +--- + +## Ops runbook (one-time, done by a human — NOT part of the coded tasks) + +1. `void secret put GITHUB_SPONSORS_WEBHOOK_SECRET` (a high-entropy random string); also add it to `.env.local` for local dev. +2. GitHub → **Your sponsors** → `napi-rs` **Dashboard** → **Webhooks** → **Add webhook**: Payload URL `https://napi.rs/webhooks/github-sponsors`, content type `application/json`, Secret = the same value, events = the default (`sponsorship`). +3. KV + R2 are auto-provisioned by `void deploy` (from the `void.json` binding flags). Ensure `GITHUB_TOKEN` (classic PAT, `read:org`+`read:user`) is set as a Void secret so the refresh can fetch the list. + +## Self-Review + +**Spec coverage:** cache (KV data + R2 images via KV manifest) → Tasks 1,3; webhook (verified, waitUntil) → Tasks 2,7; cron (daily force) → Task 7; hash-skip "unchanged" → Task 6; serve-from-cache + cold warm → Task 8; landing reads cached list → Task 8; fresh fetch for refresh → Task 4; KV-first getter → Task 5. ✅ + +**Placeholder scan:** none — every code step is complete. + +**Type consistency:** `KVStore`/`R2Store`/`SponsorsManifest`/`RenderedImage`/`ImageFormat`/`ImageTheme` defined once in `store.ts` (Task 3) and consumed unchanged by refresh/routes/cron/webhook. `RefreshDeps`/`RefreshResult`/`refreshSponsorsCache`/`hashSponsors` defined in Task 6 and consumed in Tasks 7,8. `getCachedSponsors` defined Task 5, consumed Task 8. `loadSponsors({bypassCache})` defined Task 4, consumed Tasks 6-wiring,7,8. `verifyGithubSignature` defined Task 2, consumed Task 7. ✅ + +**Ordering:** Task 3 (store) before 5,6 (import it); Task 2 (signature) before 7; Task 4 (bypassCache) before 7,8 wiring; Task 6 (refresh) before 7,8. Task 1 (bindings) first. Correct. diff --git a/docs/superpowers/plans/2026-07-06-sponsors-image-router.md b/docs/superpowers/plans/2026-07-06-sponsors-image-router.md new file mode 100644 index 00000000..0179d3e6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-sponsors-image-router.md @@ -0,0 +1,1248 @@ +# Sponsors Image Router Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Serve the napi-rs sponsor wall as an embeddable image at `GET /sponsors.svg` and `GET /sponsors.png` so it renders in GitHub, npm, and crates.io READMEs. + +**Architecture:** Two runtime Void route handlers reuse the existing live `loadSponsors()`, fetch + base64-inline each avatar, render an avatar-wall card with `satori` (→ SVG, text vectorized to ``), and for the PNG path rasterize that SVG with `@resvg/resvg-wasm` on the Cloudflare edge. Both wasm modules (`satori/yoga.wasm`, `@resvg/resvg-wasm/index_bg.wasm`) are statically imported by the route files — Void's Cloudflare build turns them into real `WebAssembly.Module` objects at the edge (verified). Heavy logic lives in small `lib/sponsors-image/*` units; the route files are thin adapters. + +**Tech Stack:** Void 0.10.x (Cloudflare Worker, Hono route handlers), `satori@0.26.0` (already installed, `satori/standalone` entry for manual yoga init), `@resvg/resvg-wasm@2.6.2` (new dep), Manrope TTFs vendored from `@expo-google-fonts/manrope`, vitest via `vp test`. + +## Global Constraints + +These bind every task. Exact values, copied verbatim. + +- **Endpoints:** `GET /sponsors.svg` (`Content-Type: image/svg+xml; charset=utf-8`) and `GET /sponsors.png` (`Content-Type: image/png`). Query param `?theme=light|dark`, **default `light`** (any value other than `dark` → `light`). +- **`Cache-Control` on both responses:** `public, s-maxage=1800, max-age=600, stale-while-revalidate=86400`. +- **Data source:** the existing `loadSponsors()` from `lib/landing/load-sponsors.ts` — `loadSponsors(): Promise`, no args, already 10-min in-isolate cached and never throws (degrades to empty tiers). Do NOT re-implement sponsor fetching. +- **Tier data keys (from `lib/landing/sponsors.ts`), in render order:** `specialThanks`, `platinum`, `gold`, `sliver`, `backers`. **The `sliver` misspelling is the real data key — never rename it.** Its human-facing label is `SILVER`. +- **satori entry:** `import satori, { init } from 'satori/standalone'`. Call `init(yogaModule)` once before `satori(...)`. Keep satori's default font embedding (text renders as ``), so **resvg needs no fonts**. +- **resvg entry:** `import { initWasm, Resvg } from '@resvg/resvg-wasm'`. Call `initWasm(resvgModule)` once. Render with `new Resvg(svg, { fitTo: { mode: 'width', value } }).render().asPng()` → `Uint8Array`. +- **wasm imports live in the route files only** (`import wasm from '….wasm'` → `WebAssembly.Module` on the edge). `lib/` units receive a `WebAssembly.Module` as a parameter and never import `.wasm`, so they stay unit-testable in Node. +- **Avatars MUST be base64 data-URI inlined** before rendering: GitHub proxies README images through Camo (external `` blocked) and the edge/resvg cannot fetch external images. Request PNG/JPEG (satori/resvg can't decode WebP); drop any avatar that fails to fetch or returns a non-`image/(png|jpeg|gif)` type. +- **Fonts:** Manrope only — `Manrope` weight 700 (headings) and weight 500 (labels/wordmark). Vendor the two TTFs into `public/fonts/` and load them at runtime via the `ASSETS` binding. Do not add Inter or Noto Sans SC (no rendered sponsor names → no CJK). +- **New dependency:** `@resvg/resvg-wasm@2.6.2` (exact). `satori@0.26.0` is already present. +- **Card width:** 800 CSS px (satori derives height). PNG rasterized at 2× (`fitTo` width 1600). +- **Test runner:** `corepack yarn vp test run ` (vitest 4). Unit tests need no `GITHUB_TOKEN`. Add `// @vitest-environment node` to any test using `node:fs` / `WebAssembly`. +- **Never commit** `dist/`, `.env.local`, `node_modules`, `.void/` (all gitignored). + +## File Structure + +``` +lib/sponsors-image/ + theme.ts Theme type, THEMES tokens, parseTheme() [Task 1] + avatars.ts inlineSponsorAvatars(), bytesToBase64() [Task 2] + fonts.ts loadFonts() (cached), readAsset() ASSETS helper [Task 3] + card.ts ensureYoga(), renderSvg(), CARD_WIDTH [Task 4] + resvg.ts ensureResvg(), svgToPng() [Task 5] + render.ts renderSponsorsImage() orchestrator [Task 6] + wasm.d.ts ambient `declare module '*.wasm'` [Task 7] + *.test.ts co-located unit tests +routes/ + sponsors.svg.ts thin adapter → SVG [Task 7] + sponsors.png.ts thin adapter → PNG [Task 7] +public/fonts/ + Manrope_700Bold.ttf, Manrope_500Medium.ttf (vendored, committed) [Task 3] +package.json + @resvg/resvg-wasm@2.6.2 [Task 5] +``` + +--- + +### Task 1: Theme tokens + +**Files:** + +- Create: `lib/sponsors-image/theme.ts` +- Test: `lib/sponsors-image/theme.test.ts` + +**Interfaces:** + +- Produces: `type Theme = 'light' | 'dark'`; `interface ThemeTokens { bg: string; fg: string; muted: string; accent: string; border: string }`; `const THEMES: Record`; `function parseTheme(value: string | null | undefined): Theme`. + +- [ ] **Step 1: Write the failing test** + +```ts +// lib/sponsors-image/theme.test.ts +import { describe, it, expect } from 'vitest' +import { parseTheme, THEMES } from './theme.ts' + +describe('parseTheme', () => { + it('defaults to light for missing/unknown values', () => { + expect(parseTheme(undefined)).toBe('light') + expect(parseTheme(null)).toBe('light') + expect(parseTheme('')).toBe('light') + expect(parseTheme('LIGHT')).toBe('light') + expect(parseTheme('purple')).toBe('light') + }) + it('returns dark only for exactly "dark"', () => { + expect(parseTheme('dark')).toBe('dark') + }) +}) + +describe('THEMES', () => { + it('has light and dark token sets with all keys', () => { + for (const theme of ['light', 'dark'] as const) { + for (const key of ['bg', 'fg', 'muted', 'accent', 'border'] as const) { + expect(THEMES[theme][key]).toMatch(/^#[0-9a-f]{6}$/i) + } + } + expect(THEMES.light.bg).not.toBe(THEMES.dark.bg) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-image/theme.test.ts` +Expected: FAIL — cannot resolve `./theme.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// lib/sponsors-image/theme.ts +// Theme tokens for the sponsors card. Two solid-background variants so the +// image is legible on both light and dark README surfaces (GitHub/npm/crates). +// `?theme=dark` opts into the dark variant; anything else is light. + +export type Theme = 'light' | 'dark' + +export interface ThemeTokens { + bg: string + fg: string + muted: string + accent: string + border: string +} + +export const THEMES: Record = { + light: { + bg: '#ffffff', + fg: '#0f172a', + muted: '#64748b', + accent: '#e66000', + border: '#e2e8f0', + }, + dark: { + bg: '#0b0d10', + fg: '#f8fafc', + muted: '#94a3b8', + accent: '#f97316', + border: '#1f2937', + }, +} + +export function parseTheme(value: string | null | undefined): Theme { + return value === 'dark' ? 'dark' : 'light' +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-image/theme.test.ts` +Expected: PASS (2 files? no — 1 file, all tests pass). + +- [ ] **Step 5: Commit** + +```bash +git add lib/sponsors-image/theme.ts lib/sponsors-image/theme.test.ts +git commit -m "feat(sponsors-image): theme tokens + parseTheme" +``` + +--- + +### Task 2: Avatar inlining + +**Files:** + +- Create: `lib/sponsors-image/avatars.ts` +- Test: `lib/sponsors-image/avatars.test.ts` + +**Interfaces:** + +- Consumes: `WashedSponsors`, `WashedSponsor` from `lib/landing/sponsors.ts` (`WashedSponsor = { name: string; img: string; url: string }`; `WashedSponsors` has the five tier arrays). +- Produces: `type ImageFetcher = (url: string) => Promise`; `function bytesToBase64(bytes: Uint8Array): string`; `function inlineSponsorAvatars(sponsors: WashedSponsors, fetchImage?: ImageFetcher): Promise` — returns a new `WashedSponsors` where every remaining sponsor's `img` is a `data:image/...;base64,...` URI. Sponsors whose avatar fails to fetch or is a non-`image/(png|jpeg|gif)` type are dropped from their tier. + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-image/avatars.test.ts +import { describe, it, expect, vi } from 'vitest' +import { + inlineSponsorAvatars, + bytesToBase64, + type ImageFetcher, +} from './avatars.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +function emptyTiers(): WashedSponsors { + return { specialThanks: [], platinum: [], gold: [], sliver: [], backers: [] } +} +function pngResponse(bytes = new Uint8Array([1, 2, 3, 4])): Response { + return new Response(bytes, { + status: 200, + headers: { 'content-type': 'image/png' }, + }) +} + +describe('bytesToBase64', () => { + it('round-trips through atob', () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]) + const b64 = bytesToBase64(bytes) + const back = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) + expect([...back]).toEqual([...bytes]) + }) +}) + +describe('inlineSponsorAvatars', () => { + it('replaces each img with a base64 data URI and preserves name/url', async () => { + const sponsors: WashedSponsors = { + ...emptyTiers(), + platinum: [ + { name: 'A', img: 'https://x/a.png', url: 'https://github.com/a' }, + ], + } + const fetchImage: ImageFetcher = vi.fn(async () => pngResponse()) + const out = await inlineSponsorAvatars(sponsors, fetchImage) + expect(out.platinum).toHaveLength(1) + expect(out.platinum[0].img).toMatch(/^data:image\/png;base64,/) + expect(out.platinum[0].name).toBe('A') + expect(out.platinum[0].url).toBe('https://github.com/a') + }) + + it('drops sponsors whose avatar fails to fetch or is not a raster image', async () => { + const sponsors: WashedSponsors = { + ...emptyTiers(), + gold: [ + { name: 'ok', img: 'https://x/ok.png', url: 'https://github.com/ok' }, + { + name: 'notfound', + img: 'https://x/404.png', + url: 'https://github.com/nf', + }, + { name: 'webp', img: 'https://x/w.webp', url: 'https://github.com/w' }, + { name: 'threw', img: 'https://x/boom', url: 'https://github.com/t' }, + ], + } + const fetchImage: ImageFetcher = async (url) => { + if (url.includes('404')) return new Response('', { status: 404 }) + if (url.includes('.webp')) + return new Response(new Uint8Array([1]), { + status: 200, + headers: { 'content-type': 'image/webp' }, + }) + if (url.includes('boom')) throw new Error('network') + return pngResponse() + } + const out = await inlineSponsorAvatars(sponsors, fetchImage) + expect(out.gold.map((s) => s.name)).toEqual(['ok']) + }) + + it('requests a sized avatar from githubusercontent hosts', async () => { + const sponsors: WashedSponsors = { + ...emptyTiers(), + backers: [ + { + name: 'g', + img: 'https://avatars.githubusercontent.com/u/1?v=4', + url: 'https://github.com/g', + }, + ], + } + const seen: string[] = [] + const fetchImage: ImageFetcher = async (url) => { + seen.push(url) + return pngResponse() + } + await inlineSponsorAvatars(sponsors, fetchImage) + expect(seen[0]).toContain('s=120') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-image/avatars.test.ts` +Expected: FAIL — cannot resolve `./avatars.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// lib/sponsors-image/avatars.ts +// Fetch each sponsor avatar and inline it as a base64 data URI. Required because +// GitHub's Camo proxy blocks external in README SVGs, and the edge +// runtime / resvg cannot fetch external images while rasterizing. We request +// PNG/JPEG (satori & resvg can't decode WebP) and drop any avatar that fails. + +import type { WashedSponsors, WashedSponsor } from '../landing/sponsors.ts' + +const TIERS = [ + 'specialThanks', + 'platinum', + 'gold', + 'sliver', + 'backers', +] as const + +export type ImageFetcher = (url: string) => Promise + +const DEFAULT_FETCH: ImageFetcher = (url) => + fetch(url, { + headers: { accept: 'image/png,image/jpeg;q=0.9,image/*;q=0.8' }, + }) + +// Uint8Array -> base64 using btoa (available in workerd + Node). Chunked so a +// large avatar doesn't blow the argument-count limit of String.fromCharCode. +export function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} + +// Append `s=120` for github avatar hosts to shrink the payload; leave other +// hosts untouched. Never throws — a malformed URL is returned as-is. +function sizedAvatarUrl(url: string): string { + try { + const u = new URL(url) + if (u.hostname.endsWith('githubusercontent.com')) + u.searchParams.set('s', '120') + return u.toString() + } catch { + return url + } +} + +async function inlineOne( + sponsor: WashedSponsor, + fetchImage: ImageFetcher, +): Promise { + try { + const res = await fetchImage(sizedAvatarUrl(sponsor.img)) + if (!res.ok) return null + const contentType = res.headers.get('content-type') ?? '' + if (!/^image\/(png|jpe?g|gif)/i.test(contentType)) return null + const bytes = new Uint8Array(await res.arrayBuffer()) + return { + ...sponsor, + img: `data:${contentType};base64,${bytesToBase64(bytes)}`, + } + } catch { + return null + } +} + +export async function inlineSponsorAvatars( + sponsors: WashedSponsors, + fetchImage: ImageFetcher = DEFAULT_FETCH, +): Promise { + const result = {} as WashedSponsors + for (const tier of TIERS) { + const inlined = await Promise.all( + sponsors[tier].map((s) => inlineOne(s, fetchImage)), + ) + result[tier] = inlined.filter((s): s is WashedSponsor => s !== null) + } + return result +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-image/avatars.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/sponsors-image/avatars.ts lib/sponsors-image/avatars.test.ts +git commit -m "feat(sponsors-image): base64-inline sponsor avatars" +``` + +--- + +### Task 3: Font loader + vendored TTFs + +**Files:** + +- Create: `lib/sponsors-image/fonts.ts` +- Test: `lib/sponsors-image/fonts.test.ts` +- Add (binary, committed): `public/fonts/Manrope_700Bold.ttf`, `public/fonts/Manrope_500Medium.ttf` + +**Interfaces:** + +- Produces: `interface SatoriFont { name: string; data: ArrayBuffer; weight: number; style: 'normal' }`; `type AssetReader = (path: string) => Promise`; `function loadFonts(read: AssetReader): Promise` (module-cached; loads Manrope 700 + 500 from `/fonts/...`); `function readAsset(assets: AssetsFetcher, baseUrl: string, path: string): Promise` where `type AssetsFetcher = { fetch(input: URL): Promise }`. + +- [ ] **Step 1: Vendor the font files** + +```bash +mkdir -p public/fonts +cp node_modules/@expo-google-fonts/manrope/700Bold/Manrope_700Bold.ttf public/fonts/Manrope_700Bold.ttf +cp node_modules/@expo-google-fonts/manrope/500Medium/Manrope_500Medium.ttf public/fonts/Manrope_500Medium.ttf +ls -la public/fonts/ +``` + +Expected: two `.ttf` files, ~96 KB and ~96 KB, non-empty. + +- [ ] **Step 2: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-image/fonts.test.ts +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { loadFonts, readAsset, type AssetReader } from './fonts.ts' + +const bold = readFileSync('public/fonts/Manrope_700Bold.ttf') +const medium = readFileSync('public/fonts/Manrope_500Medium.ttf') + +function bufferFor(path: string): ArrayBuffer { + const buf = path.includes('700') ? bold : medium + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) +} + +describe('loadFonts', () => { + it('returns Manrope 700 + 500 descriptors', async () => { + const read: AssetReader = vi.fn(async (p) => bufferFor(p)) + const fonts = await loadFonts(read) + expect(fonts.map((f) => [f.name, f.weight, f.style])).toEqual([ + ['Manrope', 700, 'normal'], + ['Manrope', 500, 'normal'], + ]) + expect(fonts[0].data.byteLength).toBeGreaterThan(1000) + }) + + it('caches after the first successful load', async () => { + const read: AssetReader = vi.fn(async (p) => bufferFor(p)) + await loadFonts(read) + await loadFonts(read) + // 2 reads on the first call (700 + 500), 0 on the cached second call + expect(read).toHaveBeenCalledTimes(2) + }) +}) + +describe('readAsset', () => { + it('fetches path against baseUrl and returns arrayBuffer', async () => { + const assets = { + fetch: vi.fn(async (input: URL) => { + expect(input.toString()).toBe('https://napi.rs/fonts/x.ttf') + return new Response(new Uint8Array([1, 2, 3]), { status: 200 }) + }), + } + const buf = await readAsset( + assets, + 'https://napi.rs/sponsors.png', + '/fonts/x.ttf', + ) + expect(new Uint8Array(buf)).toEqual(new Uint8Array([1, 2, 3])) + }) + + it('throws on a non-ok asset response', async () => { + const assets = { + fetch: vi.fn(async () => new Response('', { status: 404 })), + } + await expect( + readAsset(assets, 'https://napi.rs/', '/fonts/missing.ttf'), + ).rejects.toThrow() + }) +}) +``` + +Note: `loadFonts` is module-cached, so the two `loadFonts` tests share one cache. Keep them in this order (first populates, second asserts the cache). The `readAsset` tests are independent. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-image/fonts.test.ts` +Expected: FAIL — cannot resolve `./fonts.ts`. + +- [ ] **Step 4: Write minimal implementation** + +```ts +// lib/sponsors-image/fonts.ts +// Load the Manrope TTFs the card needs, at runtime, from the worker's own static +// assets (public/fonts/*.ttf served via the ASSETS binding). satori has no system +// fonts and needs raw TTF/OTF buffers (not woff2). Cached per isolate so we read +// each font once. A failed load clears the cache so the next request can retry. + +export interface SatoriFont { + name: string + data: ArrayBuffer + weight: number + style: 'normal' +} + +export type AssetReader = (path: string) => Promise + +export type AssetsFetcher = { fetch(input: URL): Promise } + +let cache: Promise | null = null + +export function loadFonts(read: AssetReader): Promise { + if (!cache) { + cache = load(read).catch((err) => { + cache = null + throw err + }) + } + return cache +} + +async function load(read: AssetReader): Promise { + const [bold, medium] = await Promise.all([ + read('/fonts/Manrope_700Bold.ttf'), + read('/fonts/Manrope_500Medium.ttf'), + ]) + return [ + { name: 'Manrope', data: bold, weight: 700, style: 'normal' }, + { name: 'Manrope', data: medium, weight: 500, style: 'normal' }, + ] +} + +// Read a static asset (e.g. a font) from the worker's ASSETS binding. `path` is +// an absolute path like `/fonts/x.ttf`, resolved against the current request URL. +export function readAsset( + assets: AssetsFetcher, + baseUrl: string, + path: string, +): Promise { + return assets.fetch(new URL(path, baseUrl)).then((res) => { + if (!res.ok) throw new Error(`asset ${path} failed: ${res.status}`) + return res.arrayBuffer() + }) +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-image/fonts.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/sponsors-image/fonts.ts lib/sponsors-image/fonts.test.ts public/fonts/Manrope_700Bold.ttf public/fonts/Manrope_500Medium.ttf +git commit -m "feat(sponsors-image): runtime Manrope font loader + vendored TTFs" +``` + +--- + +### Task 4: Card renderer (satori → SVG) + +**Files:** + +- Create: `lib/sponsors-image/card.ts` +- Test: `lib/sponsors-image/card.test.ts` + +**Interfaces:** + +- Consumes: `WashedSponsors` (`lib/landing/sponsors.ts`), `SatoriFont` (`./fonts.ts`), `Theme`/`THEMES` (`./theme.ts`). +- Produces: `const CARD_WIDTH = 800`; `function ensureYoga(wasm: WebAssembly.Module): Promise` (guarded, calls satori `init` once); `function renderSvg(sponsors: WashedSponsors, theme: Theme, fonts: SatoriFont[]): Promise` (returns an `` string; assumes `ensureYoga` already resolved). + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-image/card.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { ensureYoga, renderSvg, CARD_WIDTH } from './card.ts' +import type { SatoriFont } from './fonts.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +// 1x1 transparent PNG data URI — a valid inlined avatar for satori to embed. +const dot = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + +function fonts(): SatoriFont[] { + const bold = readFileSync('public/fonts/Manrope_700Bold.ttf') + const medium = readFileSync('public/fonts/Manrope_500Medium.ttf') + const toAB = (b: Buffer) => + b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) + return [ + { name: 'Manrope', data: toAB(bold), weight: 700, style: 'normal' }, + { name: 'Manrope', data: toAB(medium), weight: 500, style: 'normal' }, + ] +} +function empty(): WashedSponsors { + return { specialThanks: [], platinum: [], gold: [], sliver: [], backers: [] } +} + +beforeAll(async () => { + await ensureYoga( + new WebAssembly.Module(readFileSync('node_modules/satori/yoga.wasm')), + ) +}) + +describe('renderSvg', () => { + it('renders an svg of the configured width containing one per sponsor', async () => { + const sponsors: WashedSponsors = { + ...empty(), + platinum: [{ name: 'A', img: dot, url: 'https://github.com/a' }], + gold: [ + { name: 'B', img: dot, url: 'https://github.com/b' }, + { name: 'C', img: dot, url: 'https://github.com/c' }, + ], + } + const svg = await renderSvg(sponsors, 'light', fonts()) + expect(svg.startsWith(') when there are no sponsors', async () => { + const svg = await renderSvg(empty(), 'dark', fonts()) + expect(svg.startsWith(' and inlined +// avatars as base64 , so the SVG is fully self-contained (Camo-safe) and +// resvg needs no fonts to rasterize it. Uses the `satori/standalone` entry so we +// control yoga init explicitly (required on the Cloudflare edge). + +import satori, { init } from 'satori/standalone' +import type { WashedSponsors, WashedSponsor } from '../landing/sponsors.ts' +import type { SatoriFont } from './fonts.ts' +import { THEMES, type Theme, type ThemeTokens } from './theme.ts' + +export const CARD_WIDTH = 800 + +// Bound the backers row so a large tier can't explode avatar fan-out / image height. +const MAX_BACKERS = 100 + +interface TierSpec { + key: keyof WashedSponsors + label: string + size: number +} + +const TIER_SPECS: TierSpec[] = [ + { key: 'specialThanks', label: 'SPECIAL THANKS', size: 64 }, + { key: 'platinum', label: 'PLATINUM', size: 64 }, + { key: 'gold', label: 'GOLD', size: 48 }, + { key: 'sliver', label: 'SILVER', size: 36 }, + { key: 'backers', label: 'BACKERS', size: 28 }, +] + +let yogaReady: Promise | null = null + +export function ensureYoga(wasm: WebAssembly.Module): Promise { + if (!yogaReady) yogaReady = init(wasm) + return yogaReady +} + +// satori node helpers (avoids a JSX runtime; matches the spike-verified shape). +type Node = { type: string; props: Record } + +function avatar(sponsor: WashedSponsor, size: number): Node { + return { + type: 'img', + props: { + src: sponsor.img, + width: size, + height: size, + style: { + width: size, + height: size, + borderRadius: size / 2, + marginRight: 8, + marginBottom: 8, + }, + }, + } +} + +function tierSection( + spec: TierSpec, + list: WashedSponsor[], + t: ThemeTokens, +): Node { + return { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column', marginTop: 20 }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + color: t.muted, + fontSize: 13, + fontWeight: 500, + letterSpacing: 1, + marginBottom: 10, + }, + children: spec.label, + }, + }, + { + type: 'div', + props: { + style: { display: 'flex', flexWrap: 'wrap' }, + children: list.map((s) => avatar(s, spec.size)), + }, + }, + ], + }, + } +} + +function header(t: ThemeTokens): Node { + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + borderBottom: `1px solid ${t.border}`, + paddingBottom: 16, + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + color: t.fg, + fontSize: 30, + fontWeight: 700, + }, + children: 'NAPI-RS Sponsors', + }, + }, + { + type: 'div', + props: { + style: { + display: 'flex', + color: t.accent, + fontSize: 15, + fontWeight: 500, + marginTop: 4, + }, + children: 'napi.rs', + }, + }, + ], + }, + } +} + +export function renderSvg( + sponsors: WashedSponsors, + theme: Theme, + fonts: SatoriFont[], +): Promise { + const t = THEMES[theme] + const sections = TIER_SPECS.map((spec) => ({ + spec, + list: + spec.key === 'backers' + ? sponsors[spec.key].slice(0, MAX_BACKERS) + : sponsors[spec.key], + })) + .filter(({ list }) => list.length > 0) + .map(({ spec, list }) => tierSection(spec, list, t)) + + const root: Node = { + type: 'div', + props: { + style: { + width: '100%', + display: 'flex', + flexDirection: 'column', + background: t.bg, + padding: 40, + fontFamily: 'Manrope', + }, + children: [header(t), ...sections], + }, + } + + // satori accepts plain {type,props} nodes as ReactNode; cast keeps TS happy. + return satori(root as unknown as Parameters[0], { + width: CARD_WIDTH, + fonts, + }) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-image/card.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/sponsors-image/card.ts lib/sponsors-image/card.test.ts +git commit -m "feat(sponsors-image): satori sponsor-wall card renderer" +``` + +--- + +### Task 5: SVG → PNG rasterizer (resvg-wasm) + dependency + +**Files:** + +- Modify: `package.json` (add `@resvg/resvg-wasm@2.6.2`) +- Create: `lib/sponsors-image/resvg.ts` +- Test: `lib/sponsors-image/resvg.test.ts` + +**Interfaces:** + +- Produces: `function ensureResvg(wasm: WebAssembly.Module): Promise` (guarded `initWasm`, tolerant of "Already initialized"); `function svgToPng(svg: string, width: number): Uint8Array` (assumes `ensureResvg` resolved). + +- [ ] **Step 1: Add the dependency** + +```bash +corepack yarn add @resvg/resvg-wasm@2.6.2 +node -e "console.log(require('./package.json').dependencies['@resvg/resvg-wasm'])" +``` + +Expected: prints `2.6.2` (or `^2.6.2`). `node_modules/@resvg/resvg-wasm/index_bg.wasm` exists. + +- [ ] **Step 2: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-image/resvg.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { ensureResvg, svgToPng } from './resvg.ts' + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +beforeAll(async () => { + await ensureResvg( + new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), + ), + ) +}) + +describe('svgToPng', () => { + it('rasterizes an svg to png bytes', () => { + const svg = + '' + const png = svgToPng(svg, 20) + expect([...png.slice(0, 8)]).toEqual(PNG_MAGIC) + expect(png.length).toBeGreaterThan(50) + }) + + it('ensureResvg is idempotent (second call resolves)', async () => { + await expect( + ensureResvg( + new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), + ), + ), + ).resolves.toBeUndefined() + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-image/resvg.test.ts` +Expected: FAIL — cannot resolve `./resvg.ts`. + +- [ ] **Step 4: Write minimal implementation** + +```ts +// lib/sponsors-image/resvg.ts +// Rasterize an SVG string to PNG bytes with @resvg/resvg-wasm. Runs on the +// Cloudflare edge: the route passes the statically-imported wasm module (a real +// WebAssembly.Module) into ensureResvg once. The SVG paints its own background, +// so no resvg background option is needed; text is already vectorized by satori, +// so no fonts are needed here. + +import { initWasm, Resvg } from '@resvg/resvg-wasm' + +let resvgReady: Promise | null = null + +export function ensureResvg(wasm: WebAssembly.Module): Promise { + if (!resvgReady) { + resvgReady = initWasm(wasm).catch((err) => { + // initWasm throws if already initialized in this isolate — treat as ready. + if (String(err).includes('Already initialized')) return + resvgReady = null + throw err + }) + } + return resvgReady +} + +export function svgToPng(svg: string, width: number): Uint8Array { + const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: width } }) + return resvg.render().asPng() +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-image/resvg.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add package.json yarn.lock lib/sponsors-image/resvg.ts lib/sponsors-image/resvg.test.ts +git commit -m "feat(sponsors-image): resvg-wasm svg->png + add @resvg/resvg-wasm dep" +``` + +--- + +### Task 6: Render orchestrator + +**Files:** + +- Create: `lib/sponsors-image/render.ts` +- Test: `lib/sponsors-image/render.test.ts` + +**Interfaces:** + +- Consumes: `inlineSponsorAvatars`/`ImageFetcher` (`./avatars.ts`), `renderSvg`/`CARD_WIDTH` (`./card.ts`), `svgToPng` (`./resvg.ts`), `Theme` (`./theme.ts`), `SatoriFont` (`./fonts.ts`), `WashedSponsors` (`../landing/sponsors.ts`). +- Produces: `interface RenderOptions { format: 'svg' | 'png'; theme: Theme; sponsors: WashedSponsors; fonts: SatoriFont[]; fetchImage?: ImageFetcher }`; `interface RenderResult { body: string | Uint8Array; contentType: string }`; `function renderSponsorsImage(opts: RenderOptions): Promise`. Assumes `ensureYoga` (and `ensureResvg` for PNG) already resolved by the caller. + +- [ ] **Step 1: Write the failing test** + +```ts +// @vitest-environment node +// lib/sponsors-image/render.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { renderSponsorsImage } from './render.ts' +import { ensureYoga } from './card.ts' +import { ensureResvg } from './resvg.ts' +import type { SatoriFont } from './fonts.ts' +import type { ImageFetcher } from './avatars.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] +// a real 1x1 png so resvg can decode the embedded +const onePngBytes = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + ), + (c) => c.charCodeAt(0), +) +const fetchImage: ImageFetcher = async () => + new Response(onePngBytes, { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + +function fonts(): SatoriFont[] { + const bold = readFileSync('public/fonts/Manrope_700Bold.ttf') + const medium = readFileSync('public/fonts/Manrope_500Medium.ttf') + const toAB = (b: Buffer) => + b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) + return [ + { name: 'Manrope', data: toAB(bold), weight: 700, style: 'normal' }, + { name: 'Manrope', data: toAB(medium), weight: 500, style: 'normal' }, + ] +} +const sponsors: WashedSponsors = { + specialThanks: [], + platinum: [ + { name: 'A', img: 'https://x/a.png', url: 'https://github.com/a' }, + ], + gold: [], + sliver: [], + backers: [], +} + +beforeAll(async () => { + await ensureYoga( + new WebAssembly.Module(readFileSync('node_modules/satori/yoga.wasm')), + ) + await ensureResvg( + new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), + ), + ) +}) + +describe('renderSponsorsImage', () => { + it('returns an svg body + content-type for format=svg', async () => { + const out = await renderSponsorsImage({ + format: 'svg', + theme: 'light', + sponsors, + fonts: fonts(), + fetchImage, + }) + expect(typeof out.body).toBe('string') + expect((out.body as string).startsWith(' { + const out = await renderSponsorsImage({ + format: 'png', + theme: 'dark', + sponsors, + fonts: fonts(), + fetchImage, + }) + expect(out.body).toBeInstanceOf(Uint8Array) + expect([...(out.body as Uint8Array).slice(0, 8)]).toEqual(PNG_MAGIC) + expect(out.contentType).toBe('image/png') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `corepack yarn vp test run lib/sponsors-image/render.test.ts` +Expected: FAIL — cannot resolve `./render.ts`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// lib/sponsors-image/render.ts +// Orchestrates one sponsors-image response: inline avatars -> satori SVG -> (for +// PNG) resvg rasterize at 2x. Pure of any Hono/worker context so it is unit +// testable; the caller (route) supplies sponsors + fonts and has already run +// ensureYoga (and ensureResvg for PNG). + +import type { WashedSponsors } from '../landing/sponsors.ts' +import type { SatoriFont } from './fonts.ts' +import type { Theme } from './theme.ts' +import { inlineSponsorAvatars, type ImageFetcher } from './avatars.ts' +import { renderSvg, CARD_WIDTH } from './card.ts' +import { svgToPng } from './resvg.ts' + +const PNG_SCALE = 2 + +export interface RenderOptions { + format: 'svg' | 'png' + theme: Theme + sponsors: WashedSponsors + fonts: SatoriFont[] + fetchImage?: ImageFetcher +} + +export interface RenderResult { + body: string | Uint8Array + contentType: string +} + +export async function renderSponsorsImage( + opts: RenderOptions, +): Promise { + const inlined = await inlineSponsorAvatars(opts.sponsors, opts.fetchImage) + const svg = await renderSvg(inlined, opts.theme, opts.fonts) + if (opts.format === 'svg') { + return { body: svg, contentType: 'image/svg+xml; charset=utf-8' } + } + return { + body: svgToPng(svg, CARD_WIDTH * PNG_SCALE), + contentType: 'image/png', + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `corepack yarn vp test run lib/sponsors-image/render.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/sponsors-image/render.ts lib/sponsors-image/render.test.ts +git commit -m "feat(sponsors-image): render orchestrator (svg/png)" +``` + +--- + +### Task 7: Route handlers + wasm typing + end-to-end smoke test + +**Files:** + +- Create: `lib/sponsors-image/wasm.d.ts` +- Create: `routes/sponsors.svg.ts` +- Create: `routes/sponsors.png.ts` + +**Interfaces:** + +- Consumes: everything above, plus `loadSponsors` (`lib/landing/load-sponsors.ts`), `parseTheme` (`./theme.ts`), `defineHandler` (`void`). +- Produces: `GET /sponsors.svg` and `GET /sponsors.png` HTTP endpoints. No importable JS interface. + +This task's deliverable is verified by a build + workerd preview smoke test (route handlers can't be meaningfully unit-tested without the Void runtime; the render pipeline is already covered by Tasks 1–6, and the wasm-import→WebAssembly.Module mechanism is already proven). + +- [ ] **Step 1: Add the ambient wasm module declaration** + +```ts +// lib/sponsors-image/wasm.d.ts +// Void's Cloudflare build turns a static `import x from '….wasm'` into a real +// WebAssembly.Module at the edge. This ambient type lets the route files import +// satori/yoga.wasm and @resvg/resvg-wasm/index_bg.wasm without a TS error. +declare module '*.wasm' { + const module: WebAssembly.Module + export default module +} +``` + +- [ ] **Step 2: Write the SVG route** + +```ts +// routes/sponsors.svg.ts +// GET /sponsors.svg — the sponsor wall as a self-contained SVG (avatars inlined, +// Camo-safe) for GitHub READMEs. ?theme=light|dark (default light). +import { defineHandler } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import { loadSponsors } from '../lib/landing/load-sponsors.ts' +import { parseTheme } from '../lib/sponsors-image/theme.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { ensureYoga } from '../lib/sponsors-image/card.ts' +import { renderSponsorsImage } from '../lib/sponsors-image/render.ts' + +const CACHE_CONTROL = + 'public, s-maxage=1800, max-age=600, stale-while-revalidate=86400' + +export const GET = defineHandler(async (c) => { + await ensureYoga(yogaWasm) + const theme = parseTheme(c.req.query('theme')) + const assets = c.env.ASSETS as unknown as AssetsFetcher + const sponsors = await loadSponsors() + const fonts = await loadFonts((path) => readAsset(assets, c.req.url, path)) + const { body, contentType } = await renderSponsorsImage({ + format: 'svg', + theme, + sponsors, + fonts, + }) + return new Response(body, { + headers: { 'Content-Type': contentType, 'Cache-Control': CACHE_CONTROL }, + }) +}) +``` + +- [ ] **Step 3: Write the PNG route** + +```ts +// routes/sponsors.png.ts +// GET /sponsors.png — the sponsor wall as a PNG (works everywhere: GitHub, npm, +// crates.io). Same pipeline as the SVG route, then resvg rasterizes at 2x. +import { defineHandler } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { loadSponsors } from '../lib/landing/load-sponsors.ts' +import { parseTheme } from '../lib/sponsors-image/theme.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { ensureYoga } from '../lib/sponsors-image/card.ts' +import { ensureResvg } from '../lib/sponsors-image/resvg.ts' +import { renderSponsorsImage } from '../lib/sponsors-image/render.ts' + +const CACHE_CONTROL = + 'public, s-maxage=1800, max-age=600, stale-while-revalidate=86400' + +export const GET = defineHandler(async (c) => { + await ensureYoga(yogaWasm) + await ensureResvg(resvgWasm) + const theme = parseTheme(c.req.query('theme')) + const assets = c.env.ASSETS as unknown as AssetsFetcher + const sponsors = await loadSponsors() + const fonts = await loadFonts((path) => readAsset(assets, c.req.url, path)) + const { body, contentType } = await renderSponsorsImage({ + format: 'png', + theme, + sponsors, + fonts, + }) + return new Response(body, { + headers: { 'Content-Type': contentType, 'Cache-Control': CACHE_CONTROL }, + }) +}) +``` + +- [ ] **Step 4: Regenerate route types and build** + +Run: + +```bash +corepack yarn void prepare +corepack yarn build +``` + +Expected: `void prepare` lists `/sponsors.svg` and `/sponsors.png` in `.void/routes.d.ts`; build completes ("built in …") with no errors. (`GITHUB_TOKEN` from `.env.local` is required for the build's changelog/sponsors fetch — it is already present in the worktree.) + +- [ ] **Step 5: Smoke-test both routes in the workerd runtime** + +Run: + +```bash +(corepack yarn preview > /tmp/sponsors-preview.log 2>&1 &) ; sleep 6 +echo "--- PNG ---"; curl -s -D - -o /tmp/sponsors.png -m 30 "http://localhost:4173/sponsors.png" | grep -iE 'HTTP/|content-type|cache-control' +echo "--- SVG dark ---"; curl -s -D - -o /tmp/sponsors.svg -m 30 "http://localhost:4173/sponsors.svg?theme=dark" | grep -iE 'HTTP/|content-type|cache-control' +echo "--- PNG magic ---"; xxd -l 8 /tmp/sponsors.png +echo "--- SVG head ---"; head -c 60 /tmp/sponsors.svg; echo +echo "--- sizes ---"; ls -la /tmp/sponsors.png /tmp/sponsors.svg +pkill -f "vite preview" 2>/dev/null; pkill -f preview 2>/dev/null +``` + +Expected: + +- `/sponsors.png` → `200`, `content-type: image/png`, `cache-control: public, s-maxage=1800, max-age=600, stale-while-revalidate=86400`; `xxd` shows `8950 4e47 0d0a 1a0a`; file > 2 KB. +- `/sponsors.svg?theme=dark` → `200`, `content-type: image/svg+xml; charset=utf-8`; head starts with ` 1 KB. + +If a route 500s, read `/tmp/sponsors-preview.log` for the stack (common causes: a `.wasm` import not resolving → check the import path; fonts 404 → confirm `public/fonts/*.ttf` were built into `dist/client/fonts/`). + +- [ ] **Step 6: Commit** + +```bash +git add lib/sponsors-image/wasm.d.ts routes/sponsors.svg.ts routes/sponsors.png.ts +git commit -m "feat(sponsors-image): /sponsors.svg + /sponsors.png route handlers" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- PNG + SVG endpoints → Task 7 (routes), Task 6 (format branch). ✅ +- `?theme=light|dark` default light → Task 1 (`parseTheme`), threaded through Tasks 6–7. ✅ +- Avatars-only wall, tiers largest→smallest, `sliver`→`SILVER` label → Task 4 (`TIER_SPECS`). ✅ +- Runtime/always-fresh via `loadSponsors()` → Tasks 7. ✅ +- Edge PNG via `@resvg/resvg-wasm`, satori via `satori/standalone`, wasm as static import → Tasks 5, 4, 7. ✅ +- Avatars base64-inlined, WebP-safe → Task 2. ✅ +- Fonts vendored + loaded via ASSETS → Task 3. ✅ +- `Cache-Control` exact string → Task 7 constant (matches Global Constraints). ✅ + +**Placeholder scan:** none — every code step is complete. + +**Type consistency:** `WashedSponsors`/`WashedSponsor` (from existing `lib/landing/sponsors.ts`), `SatoriFont`, `Theme`/`ThemeTokens`, `ImageFetcher`, `AssetReader`/`AssetsFetcher`, `RenderOptions`/`RenderResult`, `CARD_WIDTH`, `ensureYoga`/`ensureResvg`/`renderSvg`/`svgToPng`/`renderSponsorsImage` are defined once and consumed with the same signatures across tasks. ✅ + +## Notes for the executor / out of scope + +- **README embed snippets** (for the napi-rs GitHub repo, the `napi` crate, and the `@napi-rs/*` npm packages) are authored in those external repos, not here. After deploy, the user embeds e.g. + `[![Sponsors](https://napi.rs/sponsors.png)](https://napi.rs/#sponsor)` for npm/crates, and on GitHub a `` with `https://napi.rs/sponsors.svg?theme=dark` / `…?theme=light` for auto theme switching. +- **Not in v1 (YAGNI):** per-sponsor clickable regions (stripped by Camo), rendered sponsor names, per-tier sub-endpoints, an in-isolate render memo (edge `s-maxage` + the existing 10-min `loadSponsors` cache suffice). +- **Do not commit** `dist/`, `.env.local`, `.void/` (gitignored). The `.env.local` (real `GITHUB_TOKEN`) already present in the worktree is needed only for local `build`/`preview`. diff --git a/env.ts b/env.ts index b46e759b..e1fdb5bd 100644 --- a/env.ts +++ b/env.ts @@ -6,4 +6,7 @@ export default defineEnv({ // GitHub Releases API token, used server-side by the changelog loaders. // Set in `.env.local` for dev and as a Void secret for prod (`void secret put GITHUB_TOKEN`). GITHUB_TOKEN: string().secret().optional(), + // GitHub Sponsors webhook signing secret, used to verify incoming webhook payloads. + // Optional so a deploy without it doesn't hard-fail; the webhook route returns 503 when unset. + GITHUB_SPONSORS_WEBHOOK_SECRET: string().secret().optional(), }) diff --git a/lib/landing/get-sponsors.test.ts b/lib/landing/get-sponsors.test.ts new file mode 100644 index 00000000..e3bd3d09 --- /dev/null +++ b/lib/landing/get-sponsors.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment node +// lib/landing/get-sponsors.test.ts +import { describe, it, expect, vi } from 'vitest' +import { getCachedSponsors } from './get-sponsors.ts' +import { DATA_KEY, type KVStore } from '../sponsors-cache/store.ts' +import type { WashedSponsors } from './sponsors.ts' + +const cached: WashedSponsors = { + specialThanks: [], + platinum: [{ name: 'KV', img: 'i', url: 'u' }], + gold: [], + sliver: [], + backers: [], +} +const live: WashedSponsors = { + specialThanks: [], + platinum: [{ name: 'LIVE', img: 'i', url: 'u' }], + gold: [], + sliver: [], + backers: [], +} + +function kvWith(entries: Record): KVStore { + return { + async get(k: string) { + return entries[k] ?? null + }, + async put() {}, + } +} + +describe('getCachedSponsors', () => { + it('returns the KV snapshot without calling live when present', async () => { + const loadLive = vi.fn(async () => live) + const out = await getCachedSponsors( + kvWith({ [DATA_KEY]: JSON.stringify(cached) }), + loadLive, + ) + expect(out.platinum[0].name).toBe('KV') + expect(loadLive).not.toHaveBeenCalled() + }) + it('falls back to live when KV is empty', async () => { + const loadLive = vi.fn(async () => live) + const out = await getCachedSponsors(kvWith({}), loadLive) + expect(out.platinum[0].name).toBe('LIVE') + expect(loadLive).toHaveBeenCalledOnce() + }) + it('falls back to live when KV binding is missing', async () => { + const loadLive = vi.fn(async () => live) + const out = await getCachedSponsors(undefined, loadLive) + expect(out.platinum[0].name).toBe('LIVE') + }) + it('corrupt KV data falls back to the live loader', async () => { + const loadLive = vi.fn(async () => live) + const out = await getCachedSponsors( + kvWith({ [DATA_KEY]: '{not json' }), + loadLive, + ) + expect(out.platinum[0].name).toBe('LIVE') + expect(loadLive).toHaveBeenCalledOnce() + }) + it('falls back to live when the KV read rejects', async () => { + const loadLive = vi.fn(async () => live) + const kv: KVStore = { + async get() { + throw new Error('KV transient failure') + }, + async put() {}, + } + const out = await getCachedSponsors(kv, loadLive) + expect(out.platinum[0].name).toBe('LIVE') + expect(loadLive).toHaveBeenCalledOnce() + }) +}) diff --git a/lib/landing/get-sponsors.ts b/lib/landing/get-sponsors.ts new file mode 100644 index 00000000..d8bd3824 --- /dev/null +++ b/lib/landing/get-sponsors.ts @@ -0,0 +1,19 @@ +// lib/landing/get-sponsors.ts +// KV-first sponsor list for consumers (landing loaders + the image cold path): +// return the cron/webhook-maintained snapshot when present, else the live GitHub +// fetch so the site still works before the first refresh. + +import type { WashedSponsors } from './sponsors.ts' +import { readData, type KVStore } from '../sponsors-cache/store.ts' +import { loadSponsors } from './load-sponsors.ts' + +export async function getCachedSponsors( + kv: KVStore | undefined, + loadLive: () => Promise = loadSponsors, +): Promise { + if (kv) { + const cached = await readData(kv) + if (cached) return cached + } + return loadLive() +} diff --git a/lib/landing/load-sponsors.test.ts b/lib/landing/load-sponsors.test.ts new file mode 100644 index 00000000..9e371f11 --- /dev/null +++ b/lib/landing/load-sponsors.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node +// lib/landing/load-sponsors.test.ts +// The loader reads `env.GITHUB_TOKEN` via void's `env` proxy, which resolves +// from `globalThis.__env__` (its Nuxt-env source) in a plain node/vitest run — +// it does NOT read `process.env`. So we seed the token in-test rather than via a +// shell prefix. Run: corepack yarn vp test run +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { loadSponsors } from './load-sponsors.ts' + +const EMPTY_PAYLOAD = { + data: { + org: { + sponsorshipsAsMaintainer: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + special: null, + }, +} + +describe('loadSponsors bypassCache', () => { + let calls: number + beforeEach(() => { + // The loader requires a token; seed it through void's `globalThis.__env__` + // source so `env.GITHUB_TOKEN` resolves (see the file header note). + ;(globalThis as unknown as { __env__: Record }).__env__ = { + GITHUB_TOKEN: 'dummy', + } + calls = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + calls += 1 + return new Response(JSON.stringify(EMPTY_PAYLOAD), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }), + ) + }) + afterEach(() => { + vi.unstubAllGlobals() + delete (globalThis as unknown as { __env__?: Record }) + .__env__ + }) + + it('caches by default and bypasses when asked', async () => { + await loadSponsors() // fetch #1, populates the in-isolate cache + expect(calls).toBe(1) + await loadSponsors() // served from cache, no fetch + expect(calls).toBe(1) + await loadSponsors({ bypassCache: true }) // forces fetch #2 + expect(calls).toBe(2) + }) +}) diff --git a/lib/landing/load-sponsors.ts b/lib/landing/load-sponsors.ts index 9721853d..161a886e 100644 --- a/lib/landing/load-sponsors.ts +++ b/lib/landing/load-sponsors.ts @@ -149,8 +149,10 @@ function bucket(nodes: SponsorshipNode[]): { // Shared sponsor loader used by all three landing loaders (en/cn/pt-BR). On any // failure (no token, network, non-200, GraphQL error, timeout) returns empty // tiers so the landing page still renders — a broken wall never 500s the page. -export async function loadSponsors(): Promise { - if (cache && cache.expiresAt > Date.now()) { +export async function loadSponsors(options?: { + bypassCache?: boolean +}): Promise { + if (!options?.bypassCache && cache && cache.expiresAt > Date.now()) { return cache.value } diff --git a/lib/sponsors-cache/refresh.test.ts b/lib/sponsors-cache/refresh.test.ts new file mode 100644 index 00000000..f69d0b37 --- /dev/null +++ b/lib/sponsors-cache/refresh.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment node +// lib/sponsors-cache/refresh.test.ts +import { describe, it, expect, beforeAll, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { + refreshSponsorsCache, + hashSponsors, + type RefreshDeps, +} from './refresh.ts' +import { + readManifest, + readImage, + readData, + type KVStore, + type R2Store, +} from './store.ts' +import { ensureYoga } from '../sponsors-image/card.ts' +import { ensureResvg } from '../sponsors-image/resvg.ts' +import type { SatoriFont } from '../sponsors-image/fonts.ts' +import type { ImageFetcher } from '../sponsors-image/avatars.ts' +import { wash, type WashedSponsors } from '../landing/sponsors.ts' + +const yogaMod = new WebAssembly.Module( + readFileSync('node_modules/satori/yoga.wasm'), +) +const resvgMod = new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), +) +const onePng = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + ), + (c) => c.charCodeAt(0), +) + +function fonts(): SatoriFont[] { + const toAB = (b: Buffer): ArrayBuffer => + b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer + return [ + { + name: 'Manrope', + data: toAB(readFileSync('public/fonts/Manrope_700Bold.ttf')), + weight: 700, + style: 'normal', + }, + { + name: 'Manrope', + data: toAB(readFileSync('public/fonts/Manrope_500Medium.ttf')), + weight: 500, + style: 'normal', + }, + ] +} +function fakeKV(): KVStore & { store: Map } { + const store = new Map() + return { + store, + async get(k) { + return store.get(k) ?? null + }, + async put(k, v) { + store.set(k, v) + }, + } +} +function fakeR2(): R2Store & { store: Map } { + const store = new Map() + return { + store, + async get(k) { + const b = store.get(k) + return b + ? { + async arrayBuffer() { + return b.buffer.slice( + b.byteOffset, + b.byteOffset + b.byteLength, + ) as ArrayBuffer + }, + } + : null + }, + async put(k, v) { + store.set( + k, + v instanceof ArrayBuffer + ? new Uint8Array(v) + : new Uint8Array((v as ArrayBufferView).buffer), + ) + }, + async delete(k) { + for (const x of Array.isArray(k) ? k : [k]) store.delete(x) + }, + } +} +const fetchImage: ImageFetcher = async () => + new Response(onePng, { + status: 200, + headers: { 'content-type': 'image/png' }, + }) +const sponsors: WashedSponsors = { + specialThanks: [], + platinum: [ + { name: 'A', img: 'https://x/a.png', url: 'https://github.com/a' }, + ], + gold: [], + sliver: [], + backers: [], +} + +function deps( + kv: KVStore, + r2: R2Store, + over: Partial = {}, +): RefreshDeps { + return { + kv, + r2, + loadFresh: async () => sponsors, + loadFonts: async () => fonts(), + fetchImage, + yogaWasm: yogaMod, + resvgWasm: resvgMod, + ...over, + } +} + +beforeAll(async () => { + await ensureYoga(yogaMod) + await ensureResvg(resvgMod) +}) + +describe('refreshSponsorsCache', () => { + it('renders 4 images, writes data + manifest + R2, and reports changed', async () => { + const kv = fakeKV(), + r2 = fakeR2() + const result = await refreshSponsorsCache(deps(kv, r2)) + expect(result.changed).toBe(true) + expect(result.imageCount).toBe(4) + expect(r2.store.size).toBe(4) + expect((await readData(kv))?.platinum[0].name).toBe('A') + const png = await readImage(kv, r2, 'png', 'dark') + expect([...png!.body.slice(0, 8)]).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]) + const svg = await readImage(kv, r2, 'svg', 'light') + expect(new TextDecoder().decode(svg!.body).startsWith(' { + const kv = fakeKV(), + r2 = fakeR2() + await refreshSponsorsCache(deps(kv, r2)) + const second = await refreshSponsorsCache(deps(kv, r2, { force: false })) + expect(second.changed).toBe(false) + expect(second.imageCount).toBe(0) + }) + + it('re-renders unchanged data when force is true', async () => { + const kv = fakeKV(), + r2 = fakeR2() + await refreshSponsorsCache(deps(kv, r2)) + const forced = await refreshSponsorsCache(deps(kv, r2, { force: true })) + expect(forced.changed).toBe(true) + expect(forced.imageCount).toBe(4) + }) + + it('degraded (empty) loadFresh does NOT overwrite an existing snapshot', async () => { + const kv = fakeKV(), + r2 = fakeR2() + // Seed a good snapshot first. + const seeded = await refreshSponsorsCache(deps(kv, r2)) + const putKv = vi.spyOn(kv, 'put') + const putR2 = vi.spyOn(r2, 'put') + // A degraded fetch (no token / non-200 / GraphQL error / timeout) washes to + // all-empty tiers; even with force it must NOT clobber the good snapshot. + const result = await refreshSponsorsCache( + deps(kv, r2, { loadFresh: async () => wash({}), force: true }), + ) + expect(result.changed).toBe(false) + expect(result.imageCount).toBe(0) + expect(result.version).toBe(seeded.version) + // No write to DATA_KEY / MANIFEST_KEY and no R2 put happened. + expect(putKv).not.toHaveBeenCalled() + expect(putR2).not.toHaveBeenCalled() + // The good data + images are preserved. + expect((await readData(kv))?.platinum[0].name).toBe('A') + expect(r2.store.size).toBe(4) + expect((await readManifest(kv))?.version).toBe(seeded.version) + }) + + it('degraded (empty) loadFresh never publishes, even on a cold cache', async () => { + const kv = fakeKV(), + r2 = fakeR2() + const putKv = vi.spyOn(kv, 'put') + const putR2 = vi.spyOn(r2, 'put') + // Never cache an outage: an all-empty (degraded) fetch must NOT write a + // snapshot even on a cold cache. Cold-misses live-render instead, and no + // concurrent good publish can be clobbered by this writer's later write. + const result = await refreshSponsorsCache( + deps(kv, r2, { loadFresh: async () => wash({}), force: true }), + ) + expect(result.changed).toBe(false) + expect(result.imageCount).toBe(0) + expect(putKv).not.toHaveBeenCalled() + expect(putR2).not.toHaveBeenCalled() + expect(r2.store.size).toBe(0) + expect(await readManifest(kv)).toBeNull() + }) + + it('hashSponsors is stable for equal data and differs for different data', async () => { + const other: WashedSponsors = { + ...sponsors, + gold: [{ name: 'G', img: 'g', url: 'u' }], + } + expect(await hashSponsors(sponsors)).toBe(await hashSponsors(sponsors)) + expect(await hashSponsors(sponsors)).not.toBe(await hashSponsors(other)) + }) +}) diff --git a/lib/sponsors-cache/refresh.ts b/lib/sponsors-cache/refresh.ts new file mode 100644 index 00000000..a03d544b --- /dev/null +++ b/lib/sponsors-cache/refresh.ts @@ -0,0 +1,131 @@ +// lib/sponsors-cache/refresh.ts +// Fetch the live sponsor list, render the 4 image blobs (svg/png x light/dark) +// once, and publish a new cache snapshot (KV data + manifest, R2 blobs). Skips +// re-rendering when the data hash matches the current manifest (unless force). +// The caller injects the wasm modules, the fresh loader, fonts, and (optionally) +// an avatar fetcher, so this is fully unit-testable in Node. + +import type { WashedSponsors } from '../landing/sponsors.ts' +import { + capBackers, + renderSvg, + CARD_WIDTH, + ensureYoga, +} from '../sponsors-image/card.ts' +import { ensureResvg, svgToPng } from '../sponsors-image/resvg.ts' +import { + inlineSponsorAvatars, + type ImageFetcher, +} from '../sponsors-image/avatars.ts' +import type { SatoriFont } from '../sponsors-image/fonts.ts' +import { + writeSnapshot, + readManifest, + type KVStore, + type R2Store, + type RenderedImage, +} from './store.ts' + +const PNG_SCALE = 2 +const THEMES = ['light', 'dark'] as const + +export interface RefreshDeps { + kv: KVStore + r2: R2Store + loadFresh: () => Promise + loadFonts: () => Promise + yogaWasm: WebAssembly.Module + resvgWasm: WebAssembly.Module + fetchImage?: ImageFetcher + force?: boolean +} + +export interface RefreshResult { + version: string + changed: boolean + imageCount: number +} + +// Short content hash (first 16 hex of SHA-256) used as the cache version + the +// R2 folder. Sponsorship changes flip it; a sponsor swapping only their avatar +// image keeps the same avatarUrl and thus the same hash (that is what the daily +// force refresh is for). +export async function hashSponsors(data: WashedSponsors): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(data)) + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest).slice(0, 8)] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +// A healthy fetch always includes the Special Thanks entry, so an all-empty +// washed list means loadSponsors degraded (no token / non-200 / GraphQL error +// / timeout — it returns empty, never throws). Used to refuse publishing a +// degraded snapshot (see the guard in refreshSponsorsCache). +function isEmpty(data: WashedSponsors): boolean { + return ( + data.specialThanks.length === 0 && + data.platinum.length === 0 && + data.gold.length === 0 && + data.sliver.length === 0 && + data.backers.length === 0 + ) +} + +export async function refreshSponsorsCache( + deps: RefreshDeps, +): Promise { + const data = await deps.loadFresh() + const version = await hashSponsors(data) + const current = await readManifest(deps.kv) + + // Degraded-fetch guard: NEVER publish an all-empty (degraded) snapshot — not + // even on a cold cache. Rationale: (1) don't cache an outage — an empty wall + // written to R2 is sticky for s-maxage, whereas skipping keeps the cache cold + // so cold-misses live-render and self-heal the moment GitHub recovers; and + // (2) the manifest is read once here, BEFORE the slow render, so a "cold + no + // manifest" empty writer that later reached writeSnapshot could clobber a + // snapshot a concurrent good refresh published in the meantime. Skipping the + // write entirely closes that race. (A healthy fetch always includes the + // Special Thanks entry, so all-empty reliably means degraded.) + if (isEmpty(data)) { + return { + version: current?.version ?? version, + changed: false, + imageCount: 0, + } + } + + // Unchanged content: skip re-render unless forced. + if (!deps.force && current?.version === version) { + return { version, changed: false, imageCount: 0 } + } + + await ensureYoga(deps.yogaWasm) + await ensureResvg(deps.resvgWasm) + const fonts = await deps.loadFonts() + const inlined = await inlineSponsorAvatars(capBackers(data), deps.fetchImage) + + const images: RenderedImage[] = [] + for (const theme of THEMES) { + const svg = await renderSvg(inlined, theme, fonts) + images.push({ + format: 'svg', + theme, + body: new TextEncoder().encode(svg), + contentType: 'image/svg+xml; charset=utf-8', + }) + const png = svgToPng(svg, CARD_WIDTH * PNG_SCALE) + images.push({ format: 'png', theme, body: png, contentType: 'image/png' }) + } + + await writeSnapshot( + deps.kv, + deps.r2, + data, + version, + images, + current?.version ?? null, + ) + return { version, changed: true, imageCount: images.length } +} diff --git a/lib/sponsors-cache/signature.test.ts b/lib/sponsors-cache/signature.test.ts new file mode 100644 index 00000000..0d299693 --- /dev/null +++ b/lib/sponsors-cache/signature.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node +// lib/sponsors-cache/signature.test.ts +import { describe, it, expect } from 'vitest' +import { verifyGithubSignature } from './signature.ts' + +// Build a valid GitHub-style signature header with Web Crypto (same as GitHub does). +async function sign(secret: string, body: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + const mac = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(body), + ) + const hex = [...new Uint8Array(mac)] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + return `sha256=${hex}` +} + +describe('verifyGithubSignature', () => { + const secret = 'topsecret' + const body = '{"action":"created"}' + + it('accepts a correct signature', async () => { + expect( + await verifyGithubSignature(secret, body, await sign(secret, body)), + ).toBe(true) + }) + it('rejects a tampered body', async () => { + expect( + await verifyGithubSignature(secret, body + 'x', await sign(secret, body)), + ).toBe(false) + }) + it('rejects a wrong secret', async () => { + expect( + await verifyGithubSignature('other', body, await sign(secret, body)), + ).toBe(false) + }) + it('rejects missing / malformed headers', async () => { + expect(await verifyGithubSignature(secret, body, undefined)).toBe(false) + expect(await verifyGithubSignature(secret, body, 'sha1=abcd')).toBe(false) + expect(await verifyGithubSignature(secret, body, 'sha256=zz')).toBe(false) + expect(await verifyGithubSignature(secret, body, 'sha256=')).toBe(false) + }) +}) diff --git a/lib/sponsors-cache/signature.ts b/lib/sponsors-cache/signature.ts new file mode 100644 index 00000000..56a13692 --- /dev/null +++ b/lib/sponsors-cache/signature.ts @@ -0,0 +1,36 @@ +// lib/sponsors-cache/signature.ts +// Verify a GitHub webhook X-Hub-Signature-256 header: HMAC-SHA256 of the RAW +// request body, hex, prefixed "sha256=". crypto.subtle.verify is constant-time. + +export async function verifyGithubSignature( + secret: string, + rawBody: string, + signatureHeader: string | null | undefined, +): Promise { + if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false + const sigBytes = hexToBytes(signatureHeader.slice('sha256='.length)) + if (!sigBytes) return false + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ) + return crypto.subtle.verify( + 'HMAC', + key, + sigBytes, + new TextEncoder().encode(rawBody), + ) +} + +function hexToBytes(hex: string): Uint8Array | null { + if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex)) + return null + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} diff --git a/lib/sponsors-cache/store.test.ts b/lib/sponsors-cache/store.test.ts new file mode 100644 index 00000000..52b95b12 --- /dev/null +++ b/lib/sponsors-cache/store.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment node +// lib/sponsors-cache/store.test.ts +import { describe, it, expect, vi } from 'vitest' +import { + readManifest, + readData, + readImage, + writeSnapshot, + imageSlot, + DATA_KEY, + MANIFEST_KEY, + type KVStore, + type R2Store, + type RenderedImage, +} from './store.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +function fakeKV(): KVStore & { store: Map } { + const store = new Map() + return { + store, + async get(key: string) { + return store.has(key) ? store.get(key)! : null + }, + async put(key: string, value: string) { + store.set(key, value) + }, + } +} +function fakeR2(): R2Store & { store: Map } { + const store = new Map() + return { + store, + async get(key: string) { + if (!store.has(key)) return null + const bytes = store.get(key)! + return { + async arrayBuffer() { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer + }, + } + }, + async put(key: string, value: ArrayBuffer | ArrayBufferView) { + store.set( + key, + value instanceof ArrayBuffer + ? new Uint8Array(value) + : new Uint8Array((value as ArrayBufferView).buffer), + ) + }, + async delete(key: string | string[]) { + for (const k of Array.isArray(key) ? key : [key]) store.delete(k) + }, + } +} +function sample(): WashedSponsors { + return { + specialThanks: [{ name: 'A', img: 'x', url: 'y' }], + platinum: [], + gold: [], + sliver: [], + backers: [], + } +} +function images(tag: string): RenderedImage[] { + return [ + { + format: 'svg', + theme: 'light', + body: new TextEncoder().encode(`${tag}L`), + contentType: 'image/svg+xml; charset=utf-8', + }, + { + format: 'svg', + theme: 'dark', + body: new TextEncoder().encode(`${tag}D`), + contentType: 'image/svg+xml; charset=utf-8', + }, + { + format: 'png', + theme: 'light', + body: new Uint8Array([1, 2, 3]), + contentType: 'image/png', + }, + { + format: 'png', + theme: 'dark', + body: new Uint8Array([4, 5, 6]), + contentType: 'image/png', + }, + ] +} + +describe('store', () => { + it('writeSnapshot persists data + manifest + 4 R2 objects; readers see them', async () => { + const kv = fakeKV(), + r2 = fakeR2() + await writeSnapshot(kv, r2, sample(), 'v1', images('a'), null) + expect(kv.store.has(DATA_KEY)).toBe(true) + expect(kv.store.has(MANIFEST_KEY)).toBe(true) + expect(r2.store.size).toBe(4) + + const data = await readData(kv) + expect(data?.specialThanks[0].name).toBe('A') + + const manifest = await readManifest(kv) + expect(manifest?.version).toBe('v1') + // Blobs live under a per-render staging folder (sponsors///…). + const pngDarkKey = manifest?.images[imageSlot('png', 'dark')].key ?? '' + expect(pngDarkKey.startsWith('sponsors/v1/')).toBe(true) + expect(pngDarkKey.endsWith('/png-dark')).toBe(true) + + const img = await readImage(kv, r2, 'png', 'dark') + expect([...img!.body]).toEqual([4, 5, 6]) + expect(img!.contentType).toBe('image/png') + }) + + it('a new snapshot version deletes the previous version R2 objects', async () => { + const kv = fakeKV(), + r2 = fakeR2() + await writeSnapshot(kv, r2, sample(), 'v1', images('a'), null) + await writeSnapshot(kv, r2, sample(), 'v2', images('b'), 'v1') + expect(r2.store.size).toBe(4) // only v2 objects remain + expect( + [...r2.store.keys()].every((k) => k.startsWith('sponsors/v2/')), + ).toBe(true) + expect((await readManifest(kv))?.version).toBe('v2') + }) + + it('readImage returns null when nothing is cached', async () => { + expect(await readImage(fakeKV(), fakeR2(), 'svg', 'light')).toBeNull() + }) + + it('readImage returns null when the R2 get rejects (manifest present)', async () => { + const kv = fakeKV(), + r2 = fakeR2() + await writeSnapshot(kv, r2, sample(), 'v1', images('a'), null) + r2.get = async () => { + throw new Error('R2 transient failure') + } + expect(await readImage(kv, r2, 'png', 'dark')).toBeNull() + }) + + it('aborts the flip (no rollback) when a newer version was published mid-render', async () => { + const kv = fakeKV(), + r2 = fakeR2() + // A concurrent refresh published "winner" while we were rendering: its + // manifest + one blob are live. Our writer started when the cache was "v1". + kv.store.set( + MANIFEST_KEY, + JSON.stringify({ + version: 'winner', + updatedAt: '', + images: { + [imageSlot('svg', 'light')]: { + key: 'sponsors/winner/svg-light', + contentType: 'image/svg+xml; charset=utf-8', + }, + }, + }), + ) + r2.store.set('sponsors/winner/svg-light', new Uint8Array([9, 9, 9])) + const deleteSpy = vi.spyOn(r2, 'delete') + + // Publish v2 based on expectedVersion 'v1' — but the pointer is now "winner", + // so the CAS must abort: don't overwrite data/manifest, drop our just-uploaded + // v2 blobs, and never touch the winner's blob. + await writeSnapshot(kv, r2, sample(), 'v2', images('b'), 'v1') + + expect((await readManifest(kv))?.version).toBe('winner') + expect(r2.store.has('sponsors/winner/svg-light')).toBe(true) + expect([...r2.store.keys()].some((k) => k.startsWith('sponsors/v2/'))).toBe( + false, + ) + // The only delete was our own orphan cleanup (v2 keys), never the winner's. + for (const call of deleteSpy.mock.calls) { + const keys = Array.isArray(call[0]) ? call[0] : [call[0]] + expect(keys.every((k) => k.startsWith('sponsors/v2/'))).toBe(true) + } + }) + + it('readData / readManifest return null on corrupt JSON', async () => { + const kv: KVStore = { + async get() { + return '{not json' + }, + async put() {}, + } + expect(await readManifest(kv)).toBeNull() + expect(await readData(kv)).toBeNull() + }) + + it('readData / readManifest return null when kv.get rejects', async () => { + const kv: KVStore = { + async get() { + throw new Error('KV transient failure') + }, + async put() {}, + } + expect(await readManifest(kv)).toBeNull() + expect(await readData(kv)).toBeNull() + }) +}) diff --git a/lib/sponsors-cache/store.ts b/lib/sponsors-cache/store.ts new file mode 100644 index 00000000..a7956d24 --- /dev/null +++ b/lib/sponsors-cache/store.ts @@ -0,0 +1,173 @@ +// lib/sponsors-cache/store.ts +// KV + R2 cache for the sponsor list and rendered images. The list JSON and a +// manifest (mapping format:theme -> R2 object key) live in KV; the image bytes +// live in R2 under a per-version folder. writeSnapshot flips the manifest last +// (atomic pointer swap) and best-effort deletes the previous version's objects. + +import type { WashedSponsors } from '../landing/sponsors.ts' + +export type ImageFormat = 'svg' | 'png' +export type ImageTheme = 'light' | 'dark' + +// Minimal structural bindings (avoids depending on @cloudflare/workers-types). +export interface KVStore { + get(key: string, opts?: { type?: 'text' }): Promise + put( + key: string, + value: string, + opts?: { expirationTtl?: number }, + ): Promise +} +export interface R2Store { + get(key: string): Promise<{ arrayBuffer(): Promise } | null> + put( + key: string, + value: ArrayBuffer | ArrayBufferView, + opts?: { httpMetadata?: { contentType?: string } }, + ): Promise + delete(key: string | string[]): Promise +} + +export interface ImageEntry { + key: string + contentType: string +} +export interface SponsorsManifest { + version: string + updatedAt: string + images: Record +} +export interface RenderedImage { + format: ImageFormat + theme: ImageTheme + body: Uint8Array + contentType: string +} + +export const DATA_KEY = 'sponsors:data' +export const MANIFEST_KEY = 'sponsors:manifest' + +export function imageSlot(format: ImageFormat, theme: ImageTheme): string { + return `${format}:${theme}` +} + +// Treat ANY cache-read failure — a rejected `kv.get` (transient KV error) or a +// corrupt/unparseable value — as a miss (null), so every caller falls through to +// its live/cold path instead of surfacing a 500. +export async function readManifest( + kv: KVStore, +): Promise { + try { + const raw = await kv.get(MANIFEST_KEY, { type: 'text' }) + return raw ? (JSON.parse(raw) as SponsorsManifest) : null + } catch { + return null + } +} + +export async function readData(kv: KVStore): Promise { + try { + const raw = await kv.get(DATA_KEY, { type: 'text' }) + return raw ? (JSON.parse(raw) as WashedSponsors) : null + } catch { + return null + } +} + +export async function readImage( + kv: KVStore, + r2: R2Store, + format: ImageFormat, + theme: ImageTheme, +): Promise<{ body: Uint8Array; contentType: string } | null> { + const manifest = await readManifest(kv) + const entry = manifest?.images[imageSlot(format, theme)] + if (!entry) return null + // A rejected R2 get / arrayBuffer (transient R2 error) is a cache miss too, so + // the image routes fall through to a live cold render instead of 500ing. + try { + const obj = await r2.get(entry.key) + if (!obj) return null + return { + body: new Uint8Array(await obj.arrayBuffer()), + contentType: entry.contentType, + } + } catch { + return null + } +} + +// Publish a snapshot with a compare-and-swap on the manifest pointer. +// `expectedVersion` is the manifest version the caller observed when it STARTED +// this refresh (null if the cache was cold). Rendering takes seconds, so a +// faster concurrent refresh may publish in the meantime; we upload our blobs +// (unique per-version keys, always safe), then re-read the manifest right before +// flipping and only publish if it still matches what we based this render on. +// If a DIFFERENT version was published while we rendered, a slower/staler writer +// must not roll the cache back — we abort and drop the blobs we just uploaded. +export async function writeSnapshot( + kv: KVStore, + r2: R2Store, + data: WashedSponsors, + version: string, + images: RenderedImage[], + expectedVersion: string | null, +): Promise { + // Stage the blobs under a per-render id so we NEVER overwrite the keys the live + // manifest currently serves — even a forced same-version re-render (the daily + // avatar refresh) writes a fresh folder. A partial write or an aborted CAS thus + // can't corrupt or roll back the live cache; readers only ever follow the + // manifest to a fully-written folder, which is published by the single atomic + // manifest flip below. + const renderId = crypto.randomUUID() + const manifest: SponsorsManifest = { + version, + updatedAt: new Date().toISOString(), + images: {}, + } + for (const img of images) { + const key = `sponsors/${version}/${renderId}/${img.format}-${img.theme}` + await r2.put(key, img.body, { + httpMetadata: { contentType: img.contentType }, + }) + manifest.images[imageSlot(img.format, img.theme)] = { + key, + contentType: img.contentType, + } + } + + // "CAS": re-read the pointer right before flipping. Abort if a concurrent + // refresh moved it to a version other than the one we started from (and other + // than our own content) — else a stale writer would roll the cache back. + // + // NOTE: Cloudflare KV has no atomic compare-and-swap / conditional put, so a + // few-ms window between this read and the kv.put below cannot be closed at the + // KV layer. Two refreshes that both start from the same manifest, fetch + // DIFFERENT data, and both clear this check before either put can still let the + // staler one publish last. That requires two refreshes overlapping within ~ms + // (sources are a daily cron + rare webhooks + rare cold-warms) with changed + // data in between, and it self-heals on the next refresh. True serialization + // would need a Durable Object (or moving the manifest to R2 for an ETag + // conditional write); both are disproportionate for a wall that refreshes + // daily and on the odd sponsorship change. Deliberately accepted. + const latest = await readManifest(kv) + const latestVersion = latest?.version ?? null + if (latestVersion !== expectedVersion && latestVersion !== version) { + const ourKeys = Object.values(manifest.images).map((e) => e.key) + if (ourKeys.length) await r2.delete(ourKeys).catch(() => {}) + return + } + + await kv.put(DATA_KEY, JSON.stringify(data)) + await kv.put(MANIFEST_KEY, JSON.stringify(manifest)) + // Best-effort cleanup of the blobs the previous manifest referenced that our + // freshly-staged folder does not (staging ids are unique, so a same-version + // re-render still cleans up the old folder rather than leaking it). + if (latest) { + const ourKeys = new Set(Object.values(manifest.images).map((e) => e.key)) + const oldKeys = Object.values(latest.images) + .map((e) => e.key) + .filter((k) => !ourKeys.has(k)) + if (oldKeys.length) await r2.delete(oldKeys).catch(() => {}) + } +} diff --git a/lib/sponsors-image/avatars.test.ts b/lib/sponsors-image/avatars.test.ts new file mode 100644 index 00000000..1cc09f35 --- /dev/null +++ b/lib/sponsors-image/avatars.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment node +// lib/sponsors-image/avatars.test.ts +import { describe, it, expect, vi } from 'vitest' +import { + inlineSponsorAvatars, + bytesToBase64, + AVATAR_CONCURRENCY, + type ImageFetcher, +} from './avatars.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +function emptyTiers(): WashedSponsors { + return { specialThanks: [], platinum: [], gold: [], sliver: [], backers: [] } +} +function pngResponse(bytes = new Uint8Array([1, 2, 3, 4])): Response { + return new Response(bytes, { + status: 200, + headers: { 'content-type': 'image/png' }, + }) +} + +describe('bytesToBase64', () => { + it('round-trips through atob', () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]) + const b64 = bytesToBase64(bytes) + const back = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) + expect([...back]).toEqual([...bytes]) + }) +}) + +describe('inlineSponsorAvatars', () => { + it('replaces each img with a base64 data URI and preserves name/url', async () => { + const sponsors: WashedSponsors = { + ...emptyTiers(), + platinum: [ + { name: 'A', img: 'https://x/a.png', url: 'https://github.com/a' }, + ], + } + const fetchImage: ImageFetcher = vi.fn(async () => pngResponse()) + const out = await inlineSponsorAvatars(sponsors, fetchImage) + expect(out.platinum).toHaveLength(1) + expect(out.platinum[0].img).toMatch(/^data:image\/png;base64,/) + expect(out.platinum[0].name).toBe('A') + expect(out.platinum[0].url).toBe('https://github.com/a') + }) + + it('drops sponsors whose avatar fails to fetch or is not a raster image', async () => { + const sponsors: WashedSponsors = { + ...emptyTiers(), + gold: [ + { name: 'ok', img: 'https://x/ok.png', url: 'https://github.com/ok' }, + { + name: 'notfound', + img: 'https://x/404.png', + url: 'https://github.com/nf', + }, + { name: 'webp', img: 'https://x/w.webp', url: 'https://github.com/w' }, + { name: 'threw', img: 'https://x/boom', url: 'https://github.com/t' }, + ], + } + const fetchImage: ImageFetcher = async (url) => { + if (url.includes('404')) return new Response('', { status: 404 }) + if (url.includes('.webp')) + return new Response(new Uint8Array([1]), { + status: 200, + headers: { 'content-type': 'image/webp' }, + }) + if (url.includes('boom')) throw new Error('network') + return pngResponse() + } + const out = await inlineSponsorAvatars(sponsors, fetchImage) + expect(out.gold.map((s) => s.name)).toEqual(['ok']) + }) + + it('drops an avatar whose fetch hangs past the timeout', async () => { + const sponsors: WashedSponsors = { + ...emptyTiers(), + platinum: [ + { + name: 'slow', + img: 'https://x/slow.png', + url: 'https://github.com/s', + }, + ], + } + const hanging: ImageFetcher = (_url, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('aborted'))) + }) + const out = await inlineSponsorAvatars(sponsors, hanging, 20) + expect(out.platinum).toHaveLength(0) + }) + + it('requests a sized avatar from githubusercontent hosts', async () => { + const sponsors: WashedSponsors = { + ...emptyTiers(), + backers: [ + { + name: 'g', + img: 'https://avatars.githubusercontent.com/u/1?v=4', + url: 'https://github.com/g', + }, + ], + } + const seen: string[] = [] + const fetchImage: ImageFetcher = async (url) => { + seen.push(url) + return pngResponse() + } + await inlineSponsorAvatars(sponsors, fetchImage) + expect(seen[0]).toContain('s=120') + }) + + it('caps concurrent avatar fetches at AVATAR_CONCURRENCY and keeps all successes', async () => { + const many = Array.from({ length: 20 }, (_v, i) => ({ + name: `b${i}`, + img: `https://x/${i}.png`, + url: `https://github.com/b${i}`, + })) + const sponsors: WashedSponsors = { ...emptyTiers(), backers: many } + let inFlight = 0 + let maxInFlight = 0 + const fetchImage: ImageFetcher = async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 5)) + inFlight -= 1 + return pngResponse() + } + const out = await inlineSponsorAvatars(sponsors, fetchImage) + expect(out.backers).toHaveLength(20) + expect(maxInFlight).toBeGreaterThan(0) + expect(maxInFlight).toBeLessThanOrEqual(AVATAR_CONCURRENCY) + }) +}) diff --git a/lib/sponsors-image/avatars.ts b/lib/sponsors-image/avatars.ts new file mode 100644 index 00000000..6d77c81d --- /dev/null +++ b/lib/sponsors-image/avatars.ts @@ -0,0 +1,128 @@ +// lib/sponsors-image/avatars.ts +// Fetch each sponsor avatar and inline it as a base64 data URI. Required because +// GitHub's Camo proxy blocks external in README SVGs, and the edge +// runtime / resvg cannot fetch external images while rasterizing. We request +// PNG/JPEG (satori & resvg can't decode WebP) and drop any avatar that fails. + +import type { WashedSponsors, WashedSponsor } from '../landing/sponsors.ts' + +const TIERS = [ + 'specialThanks', + 'platinum', + 'gold', + 'sliver', + 'backers', +] as const + +const AVATAR_TIMEOUT_MS = 3000 + +// Cloudflare Workers allow ~6 simultaneous outbound connections. Cap concurrent +// avatar fetches at that so a queued fetch doesn't burn its timeout while waiting +// for a slot — inlineOne (which starts the per-fetch timeout) only runs when a +// slot frees. https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections +export const AVATAR_CONCURRENCY = 6 + +export type ImageFetcher = ( + url: string, + signal?: AbortSignal, +) => Promise + +const DEFAULT_FETCH: ImageFetcher = (url, signal) => + fetch(url, { + headers: { accept: 'image/png,image/jpeg;q=0.9,image/*;q=0.8' }, + signal, + }) + +// Uint8Array -> base64 using btoa (available in workerd + Node). Chunked so a +// large avatar doesn't blow the argument-count limit of String.fromCharCode. +export function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} + +// Append `s=120` for github avatar hosts to shrink the payload; leave other +// hosts untouched. Never throws — a malformed URL is returned as-is. +function sizedAvatarUrl(url: string): string { + try { + const u = new URL(url) + if (u.hostname.endsWith('githubusercontent.com')) + u.searchParams.set('s', '120') + return u.toString() + } catch { + return url + } +} + +async function inlineOne( + sponsor: WashedSponsor, + fetchImage: ImageFetcher, + timeoutMs: number, +): Promise { + try { + const res = await fetchImage( + sizedAvatarUrl(sponsor.img), + AbortSignal.timeout(timeoutMs), + ) + if (!res.ok) return null + const contentType = res.headers.get('content-type') ?? '' + if (!/^image\/(png|jpe?g|gif)/i.test(contentType)) return null + const bytes = new Uint8Array(await res.arrayBuffer()) + return { + ...sponsor, + img: `data:${contentType};base64,${bytesToBase64(bytes)}`, + } + } catch { + return null + } +} + +// Run `fn` over `items` with at most `limit` concurrent calls, preserving order. +async function mapLimit( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results = new Array(items.length) + let cursor = 0 + async function worker(): Promise { + while (cursor < items.length) { + const index = cursor++ + results[index] = await fn(items[index]) + } + } + const workers = Array.from({ length: Math.min(limit, items.length) }, () => + worker(), + ) + await Promise.all(workers) + return results +} + +export async function inlineSponsorAvatars( + sponsors: WashedSponsors, + fetchImage: ImageFetcher = DEFAULT_FETCH, + timeoutMs: number = AVATAR_TIMEOUT_MS, +): Promise { + const flat: { tier: (typeof TIERS)[number]; sponsor: WashedSponsor }[] = [] + for (const tier of TIERS) { + for (const sponsor of sponsors[tier]) flat.push({ tier, sponsor }) + } + const inlined = await mapLimit(flat, AVATAR_CONCURRENCY, (item) => + inlineOne(item.sponsor, fetchImage, timeoutMs), + ) + const result: WashedSponsors = { + specialThanks: [], + platinum: [], + gold: [], + sliver: [], + backers: [], + } + flat.forEach((item, i) => { + const one = inlined[i] + if (one) result[item.tier].push(one) + }) + return result +} diff --git a/lib/sponsors-image/card-yoga-init.test.ts b/lib/sponsors-image/card-yoga-init.test.ts new file mode 100644 index 00000000..4b6e1bfa --- /dev/null +++ b/lib/sponsors-image/card-yoga-init.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest' + +// Force satori's init to reject once, then succeed, to prove ensureYoga retries. +vi.mock('satori/standalone', () => { + let calls = 0 + return { + default: vi.fn(), + init: vi.fn(async () => { + calls += 1 + if (calls === 1) throw new Error('yoga boom') + }), + } +}) + +import { ensureYoga } from './card.ts' + +describe('ensureYoga', () => { + it('clears its cached promise on a rejected init so a later call retries', async () => { + const fakeModule = {} as WebAssembly.Module + await expect(ensureYoga(fakeModule)).rejects.toThrow('yoga boom') + await expect(ensureYoga(fakeModule)).resolves.toBeUndefined() + }) +}) diff --git a/lib/sponsors-image/card.test.ts b/lib/sponsors-image/card.test.ts new file mode 100644 index 00000000..d39f8948 --- /dev/null +++ b/lib/sponsors-image/card.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +// lib/sponsors-image/card.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { ensureYoga, renderSvg, CARD_WIDTH } from './card.ts' +import type { SatoriFont } from './fonts.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +// 1x1 transparent PNG data URI — a valid inlined avatar for satori to embed. +const dot = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + +function fonts(): SatoriFont[] { + const bold = readFileSync('public/fonts/Manrope_700Bold.ttf') + const medium = readFileSync('public/fonts/Manrope_500Medium.ttf') + const toAB = (b: Buffer): ArrayBuffer => + b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer + return [ + { name: 'Manrope', data: toAB(bold), weight: 700, style: 'normal' }, + { name: 'Manrope', data: toAB(medium), weight: 500, style: 'normal' }, + ] +} +function empty(): WashedSponsors { + return { specialThanks: [], platinum: [], gold: [], sliver: [], backers: [] } +} + +beforeAll(async () => { + await ensureYoga( + new WebAssembly.Module(readFileSync('node_modules/satori/yoga.wasm')), + ) +}) + +describe('renderSvg', () => { + it('renders an svg of the configured width containing one per sponsor', async () => { + const sponsors: WashedSponsors = { + ...empty(), + platinum: [{ name: 'A', img: dot, url: 'https://github.com/a' }], + gold: [ + { name: 'B', img: dot, url: 'https://github.com/b' }, + { name: 'C', img: dot, url: 'https://github.com/c' }, + ], + } + const svg = await renderSvg(sponsors, 'light', fonts()) + expect(svg.startsWith(') when there are no sponsors', async () => { + const svg = await renderSvg(empty(), 'dark', fonts()) + expect(svg.startsWith(' and inlined +// avatars as base64 , so the SVG is fully self-contained (Camo-safe) and +// resvg needs no fonts to rasterize it. Uses the `satori/standalone` entry so we +// control yoga init explicitly (required on the Cloudflare edge). + +import satori, { init } from 'satori/standalone' +import type { WashedSponsors, WashedSponsor } from '../landing/sponsors.ts' +import type { SatoriFont } from './fonts.ts' +import { THEMES, type Theme, type ThemeTokens } from './theme.ts' + +export const CARD_WIDTH = 800 + +// Bound the backers row so a large tier can't explode avatar fan-out / image height. +export const MAX_BACKERS = 100 + +// Cap the backers tier BEFORE avatar fetching, so an oversized list can't fan +// out hundreds of avatar fetches on the edge (a resource bound, not just layout). +export function capBackers(sponsors: WashedSponsors): WashedSponsors { + if (sponsors.backers.length <= MAX_BACKERS) return sponsors + return { ...sponsors, backers: sponsors.backers.slice(0, MAX_BACKERS) } +} + +interface TierSpec { + key: keyof WashedSponsors + label: string + size: number +} + +const TIER_SPECS: TierSpec[] = [ + { key: 'specialThanks', label: 'SPECIAL THANKS', size: 64 }, + { key: 'platinum', label: 'PLATINUM', size: 64 }, + { key: 'gold', label: 'GOLD', size: 48 }, + { key: 'sliver', label: 'SILVER', size: 36 }, + { key: 'backers', label: 'BACKERS', size: 28 }, +] + +let yogaReady: Promise | null = null + +export function ensureYoga(wasm: WebAssembly.Module): Promise { + if (!yogaReady) { + yogaReady = init(wasm).catch((err) => { + yogaReady = null + throw err + }) + } + return yogaReady +} + +// satori node helpers (avoids a JSX runtime; matches the spike-verified shape). +type Node = { type: string; props: Record } + +function avatar(sponsor: WashedSponsor, size: number): Node { + return { + type: 'img', + props: { + src: sponsor.img, + width: size, + height: size, + style: { + width: size, + height: size, + borderRadius: size / 2, + marginRight: 8, + marginBottom: 8, + }, + }, + } +} + +function tierSection( + spec: TierSpec, + list: WashedSponsor[], + t: ThemeTokens, +): Node { + return { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column', marginTop: 20 }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + color: t.muted, + fontSize: 13, + fontWeight: 500, + letterSpacing: 1, + marginBottom: 10, + }, + children: spec.label, + }, + }, + { + type: 'div', + props: { + style: { display: 'flex', flexWrap: 'wrap' }, + children: list.map((s) => avatar(s, spec.size)), + }, + }, + ], + }, + } +} + +function header(t: ThemeTokens): Node { + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + borderBottom: `1px solid ${t.border}`, + paddingBottom: 16, + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + color: t.fg, + fontSize: 30, + fontWeight: 700, + }, + children: 'NAPI-RS Sponsors', + }, + }, + { + type: 'div', + props: { + style: { + display: 'flex', + color: t.accent, + fontSize: 15, + fontWeight: 500, + marginTop: 4, + }, + children: 'napi.rs', + }, + }, + ], + }, + } +} + +export function renderSvg( + sponsors: WashedSponsors, + theme: Theme, + fonts: SatoriFont[], +): Promise { + const t = THEMES[theme] + const sections = TIER_SPECS.map((spec) => ({ + spec, + list: sponsors[spec.key], + })) + .filter(({ list }) => list.length > 0) + .map(({ spec, list }) => tierSection(spec, list, t)) + + const root: Node = { + type: 'div', + props: { + style: { + width: '100%', + display: 'flex', + flexDirection: 'column', + background: t.bg, + padding: 40, + fontFamily: 'Manrope', + }, + children: [header(t), ...sections], + }, + } + + // satori accepts plain {type,props} nodes as ReactNode; cast keeps TS happy. + return satori(root as unknown as Parameters[0], { + width: CARD_WIDTH, + fonts, + }) +} diff --git a/lib/sponsors-image/fonts.test.ts b/lib/sponsors-image/fonts.test.ts new file mode 100644 index 00000000..9a6df2cc --- /dev/null +++ b/lib/sponsors-image/fonts.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node +// lib/sponsors-image/fonts.test.ts +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { loadFonts, readAsset, type AssetReader } from './fonts.ts' + +const bold = readFileSync('public/fonts/Manrope_700Bold.ttf') +const medium = readFileSync('public/fonts/Manrope_500Medium.ttf') + +function bufferFor(path: string): ArrayBuffer { + const buf = path.includes('700') ? bold : medium + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) +} + +describe('loadFonts', () => { + // One shared mock across both tests: loadFonts is module-cached, so test 1 + // populates the cache (2 reads) and test 2 asserts the later loads are served + // from that same cache (still 2 reads total, not 4). A per-test mock would see + // 0 calls in test 2 because the cache is already warm from test 1. + const read: AssetReader = vi.fn(async (p) => bufferFor(p)) + + it('returns Manrope 700 + 500 descriptors', async () => { + const fonts = await loadFonts(read) + expect(fonts.map((f) => [f.name, f.weight, f.style])).toEqual([ + ['Manrope', 700, 'normal'], + ['Manrope', 500, 'normal'], + ]) + expect(fonts[0].data.byteLength).toBeGreaterThan(1000) + }) + + it('caches after the first successful load', async () => { + await loadFonts(read) + await loadFonts(read) + // 2 reads total from test 1's populate; both loads here hit the cache. + expect(read).toHaveBeenCalledTimes(2) + }) +}) + +describe('readAsset', () => { + it('fetches path against baseUrl and returns arrayBuffer', async () => { + const assets = { + fetch: vi.fn(async (input: URL) => { + expect(input.toString()).toBe('https://napi.rs/fonts/x.ttf') + return new Response(new Uint8Array([1, 2, 3]), { status: 200 }) + }), + } + const buf = await readAsset( + assets, + 'https://napi.rs/sponsors.png', + '/fonts/x.ttf', + ) + expect(new Uint8Array(buf)).toEqual(new Uint8Array([1, 2, 3])) + }) + + it('throws on a non-ok asset response', async () => { + const assets = { + fetch: vi.fn(async () => new Response('', { status: 404 })), + } + await expect( + readAsset(assets, 'https://napi.rs/', '/fonts/missing.ttf'), + ).rejects.toThrow() + }) +}) diff --git a/lib/sponsors-image/fonts.ts b/lib/sponsors-image/fonts.ts new file mode 100644 index 00000000..203dc594 --- /dev/null +++ b/lib/sponsors-image/fonts.ts @@ -0,0 +1,54 @@ +// lib/sponsors-image/fonts.ts +// Load the Manrope TTFs the card needs, at runtime, from the worker's own static +// assets (public/fonts/*.ttf served via the ASSETS binding). satori has no system +// fonts and needs raw TTF/OTF buffers (not woff2). Cached per isolate so we read +// each font once. A failed load clears the cache so the next request can retry. + +import type { FontWeight } from 'satori/standalone' + +export interface SatoriFont { + name: string + data: ArrayBuffer + weight: FontWeight + style: 'normal' +} + +export type AssetReader = (path: string) => Promise + +export type AssetsFetcher = { fetch(input: URL): Promise } + +let cache: Promise | null = null + +export function loadFonts(read: AssetReader): Promise { + if (!cache) { + cache = load(read).catch((err) => { + cache = null + throw err + }) + } + return cache +} + +async function load(read: AssetReader): Promise { + const [bold, medium] = await Promise.all([ + read('/fonts/Manrope_700Bold.ttf'), + read('/fonts/Manrope_500Medium.ttf'), + ]) + return [ + { name: 'Manrope', data: bold, weight: 700, style: 'normal' }, + { name: 'Manrope', data: medium, weight: 500, style: 'normal' }, + ] +} + +// Read a static asset (e.g. a font) from the worker's ASSETS binding. `path` is +// an absolute path like `/fonts/x.ttf`, resolved against the current request URL. +export function readAsset( + assets: AssetsFetcher, + baseUrl: string, + path: string, +): Promise { + return assets.fetch(new URL(path, baseUrl)).then((res) => { + if (!res.ok) throw new Error(`asset ${path} failed: ${res.status}`) + return res.arrayBuffer() + }) +} diff --git a/lib/sponsors-image/render.test.ts b/lib/sponsors-image/render.test.ts new file mode 100644 index 00000000..561a416d --- /dev/null +++ b/lib/sponsors-image/render.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment node +// lib/sponsors-image/render.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { renderSponsorsImage } from './render.ts' +import { ensureYoga, MAX_BACKERS } from './card.ts' +import { ensureResvg } from './resvg.ts' +import type { SatoriFont } from './fonts.ts' +import type { ImageFetcher } from './avatars.ts' +import type { WashedSponsors } from '../landing/sponsors.ts' + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] +// a real 1x1 png so resvg can decode the embedded +const onePngBytes = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + ), + (c) => c.charCodeAt(0), +) +const fetchImage: ImageFetcher = async () => + new Response(onePngBytes, { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + +function fonts(): SatoriFont[] { + const bold = readFileSync('public/fonts/Manrope_700Bold.ttf') + const medium = readFileSync('public/fonts/Manrope_500Medium.ttf') + const toAB = (b: Buffer): ArrayBuffer => + b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer + return [ + { name: 'Manrope', data: toAB(bold), weight: 700, style: 'normal' }, + { name: 'Manrope', data: toAB(medium), weight: 500, style: 'normal' }, + ] +} +const sponsors: WashedSponsors = { + specialThanks: [], + platinum: [ + { name: 'A', img: 'https://x/a.png', url: 'https://github.com/a' }, + ], + gold: [], + sliver: [], + backers: [], +} + +beforeAll(async () => { + await ensureYoga( + new WebAssembly.Module(readFileSync('node_modules/satori/yoga.wasm')), + ) + await ensureResvg( + new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), + ), + ) +}) + +describe('renderSponsorsImage', () => { + it('returns an svg body + content-type for format=svg', async () => { + const out = await renderSponsorsImage({ + format: 'svg', + theme: 'light', + sponsors, + fonts: fonts(), + fetchImage, + }) + expect(typeof out.body).toBe('string') + expect((out.body as string).startsWith(' { + const many = Array.from({ length: MAX_BACKERS + 50 }, (_v, i) => ({ + name: `b${i}`, + img: `https://x/${i}.png`, + url: `https://github.com/b${i}`, + })) + const manySponsors: WashedSponsors = { + specialThanks: [], + platinum: [], + gold: [], + sliver: [], + backers: many, + } + let calls = 0 + const counting: ImageFetcher = async () => { + calls += 1 + return new Response(onePngBytes, { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + } + await renderSponsorsImage({ + format: 'svg', + theme: 'light', + sponsors: manySponsors, + fonts: fonts(), + fetchImage: counting, + }) + expect(calls).toBe(MAX_BACKERS) + }) + + it('returns png bytes + content-type for format=png', async () => { + const out = await renderSponsorsImage({ + format: 'png', + theme: 'dark', + sponsors, + fonts: fonts(), + fetchImage, + }) + expect(out.body).toBeInstanceOf(Uint8Array) + expect([...(out.body as Uint8Array).slice(0, 8)]).toEqual(PNG_MAGIC) + expect(out.contentType).toBe('image/png') + }) +}) diff --git a/lib/sponsors-image/render.ts b/lib/sponsors-image/render.ts new file mode 100644 index 00000000..832e9906 --- /dev/null +++ b/lib/sponsors-image/render.ts @@ -0,0 +1,42 @@ +// lib/sponsors-image/render.ts +// Orchestrates one sponsors-image response: inline avatars -> satori SVG -> (for +// PNG) resvg rasterize at 2x. Pure of any Hono/worker context so it is unit +// testable; the caller (route) supplies sponsors + fonts and has already run +// ensureYoga (and ensureResvg for PNG). + +import type { WashedSponsors } from '../landing/sponsors.ts' +import type { SatoriFont } from './fonts.ts' +import type { Theme } from './theme.ts' +import { inlineSponsorAvatars, type ImageFetcher } from './avatars.ts' +import { renderSvg, CARD_WIDTH, capBackers } from './card.ts' +import { svgToPng } from './resvg.ts' + +const PNG_SCALE = 2 + +export interface RenderOptions { + format: 'svg' | 'png' + theme: Theme + sponsors: WashedSponsors + fonts: SatoriFont[] + fetchImage?: ImageFetcher +} + +export interface RenderResult { + body: string | Uint8Array + contentType: string +} + +export async function renderSponsorsImage( + opts: RenderOptions, +): Promise { + const capped = capBackers(opts.sponsors) + const inlined = await inlineSponsorAvatars(capped, opts.fetchImage) + const svg = await renderSvg(inlined, opts.theme, opts.fonts) + if (opts.format === 'svg') { + return { body: svg, contentType: 'image/svg+xml; charset=utf-8' } + } + return { + body: svgToPng(svg, CARD_WIDTH * PNG_SCALE), + contentType: 'image/png', + } +} diff --git a/lib/sponsors-image/resvg.test.ts b/lib/sponsors-image/resvg.test.ts new file mode 100644 index 00000000..e6814599 --- /dev/null +++ b/lib/sponsors-image/resvg.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node +// lib/sponsors-image/resvg.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { ensureResvg, svgToPng } from './resvg.ts' + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +beforeAll(async () => { + await ensureResvg( + new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), + ), + ) +}) + +describe('svgToPng', () => { + it('rasterizes an svg to png bytes', () => { + const svg = + '' + const png = svgToPng(svg, 20) + expect([...png.slice(0, 8)]).toEqual(PNG_MAGIC) + expect(png.length).toBeGreaterThan(50) + }) + + it('ensureResvg is idempotent (second call resolves)', async () => { + await expect( + ensureResvg( + new WebAssembly.Module( + readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm'), + ), + ), + ).resolves.toBeUndefined() + }) +}) diff --git a/lib/sponsors-image/resvg.ts b/lib/sponsors-image/resvg.ts new file mode 100644 index 00000000..2dc93865 --- /dev/null +++ b/lib/sponsors-image/resvg.ts @@ -0,0 +1,27 @@ +// lib/sponsors-image/resvg.ts +// Rasterize an SVG string to PNG bytes with @resvg/resvg-wasm. Runs on the +// Cloudflare edge: the route passes the statically-imported wasm module (a real +// WebAssembly.Module) into ensureResvg once. The SVG paints its own background, +// so no resvg background option is needed; text is already vectorized by satori, +// so no fonts are needed here. + +import { initWasm, Resvg } from '@resvg/resvg-wasm' + +let resvgReady: Promise | null = null + +export function ensureResvg(wasm: WebAssembly.Module): Promise { + if (!resvgReady) { + resvgReady = initWasm(wasm).catch((err) => { + // initWasm throws if already initialized in this isolate — treat as ready. + if (String(err).includes('Already initialized')) return + resvgReady = null + throw err + }) + } + return resvgReady +} + +export function svgToPng(svg: string, width: number): Uint8Array { + const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: width } }) + return resvg.render().asPng() +} diff --git a/lib/sponsors-image/theme.test.ts b/lib/sponsors-image/theme.test.ts new file mode 100644 index 00000000..5203e5f3 --- /dev/null +++ b/lib/sponsors-image/theme.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { parseTheme, THEMES } from './theme.ts' + +describe('parseTheme', () => { + it('defaults to light for missing/unknown values', () => { + expect(parseTheme(undefined)).toBe('light') + expect(parseTheme(null)).toBe('light') + expect(parseTheme('')).toBe('light') + expect(parseTheme('LIGHT')).toBe('light') + expect(parseTheme('purple')).toBe('light') + }) + it('returns dark only for exactly "dark"', () => { + expect(parseTheme('dark')).toBe('dark') + }) +}) + +describe('THEMES', () => { + it('has light and dark token sets with all keys', () => { + for (const theme of ['light', 'dark'] as const) { + for (const key of ['bg', 'fg', 'muted', 'accent', 'border'] as const) { + expect(THEMES[theme][key]).toMatch(/^#[0-9a-f]{6}$/i) + } + } + expect(THEMES.light.bg).not.toBe(THEMES.dark.bg) + }) +}) diff --git a/lib/sponsors-image/theme.ts b/lib/sponsors-image/theme.ts new file mode 100644 index 00000000..0e2eb569 --- /dev/null +++ b/lib/sponsors-image/theme.ts @@ -0,0 +1,34 @@ +// Theme tokens for the sponsors card. Two solid-background variants so the +// image is legible on both light and dark README surfaces (GitHub/npm/crates). +// `?theme=dark` opts into the dark variant; anything else is light. + +export type Theme = 'light' | 'dark' + +export interface ThemeTokens { + bg: string + fg: string + muted: string + accent: string + border: string +} + +export const THEMES: Record = { + light: { + bg: '#ffffff', + fg: '#0f172a', + muted: '#64748b', + accent: '#e66000', + border: '#e2e8f0', + }, + dark: { + bg: '#0b0d10', + fg: '#f8fafc', + muted: '#94a3b8', + accent: '#f97316', + border: '#1f2937', + }, +} + +export function parseTheme(value: string | null | undefined): Theme { + return value === 'dark' ? 'dark' : 'light' +} diff --git a/lib/sponsors-image/wasm.d.ts b/lib/sponsors-image/wasm.d.ts new file mode 100644 index 00000000..31102c4e --- /dev/null +++ b/lib/sponsors-image/wasm.d.ts @@ -0,0 +1,8 @@ +// lib/sponsors-image/wasm.d.ts +// Void's Cloudflare build turns a static `import x from '….wasm'` into a real +// WebAssembly.Module at the edge. This ambient type lets the route files import +// satori/yoga.wasm and @resvg/resvg-wasm/index_bg.wasm without a TS error. +declare module '*.wasm' { + const module: WebAssembly.Module + export default module +} diff --git a/package.json b/package.json index af00cd47..51acce2f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-popover": "^1.1.18", "@radix-ui/react-progress": "^1.1.11", "@radix-ui/react-slot": "^1.3.0", + "@resvg/resvg-wasm": "2.6.2", "@void/md": "0.10.5", "@void/react": "0.10.5", "@waaark/luge": "^0.6.20-beta", diff --git a/pages/cn/index.server.ts b/pages/cn/index.server.ts index 0f9609bb..b05d26cf 100644 --- a/pages/cn/index.server.ts +++ b/pages/cn/index.server.ts @@ -1,13 +1,14 @@ import { defineHandler, defineHead, type InferProps } from 'void' -import { loadSponsors } from '../../lib/landing/load-sponsors.ts' +import { getCachedSponsors } from '../../lib/landing/get-sponsors.ts' +import type { KVStore } from '../../lib/sponsors-cache/store.ts' // prerender=false keeps the scoped COOP/COEP headers (void.json routing.headers // `/cn`) applying on every request, matching `/en`. export const prerender = false -export const loader = defineHandler(async () => ({ - sponsors: await loadSponsors(), +export const loader = defineHandler(async (c) => ({ + sponsors: await getCachedSponsors((c.env as unknown as { KV?: KVStore }).KV), })) export type Props = InferProps diff --git a/pages/en/index.server.ts b/pages/en/index.server.ts index 4dba54d0..44a4707e 100644 --- a/pages/en/index.server.ts +++ b/pages/en/index.server.ts @@ -1,6 +1,7 @@ import { defineHandler, defineHead, type InferProps } from 'void' -import { loadSponsors } from '../../lib/landing/load-sponsors.ts' +import { getCachedSponsors } from '../../lib/landing/get-sponsors.ts' +import type { KVStore } from '../../lib/sponsors-cache/store.ts' // Opt OUT of auto-prerender so the per-request COOP/COEP isolation headers from // void.json (routing.headers `/en`) are applied on every request — a prerendered @@ -9,8 +10,8 @@ import { loadSponsors } from '../../lib/landing/load-sponsors.ts' export const prerender = false // Loader runs on GET; its return becomes the page (default export) props. -export const loader = defineHandler(async () => ({ - sponsors: await loadSponsors(), +export const loader = defineHandler(async (c) => ({ + sponsors: await getCachedSponsors((c.env as unknown as { KV?: KVStore }).KV), })) export type Props = InferProps diff --git a/pages/pt-BR/index.server.ts b/pages/pt-BR/index.server.ts index 621d17c7..79f69e49 100644 --- a/pages/pt-BR/index.server.ts +++ b/pages/pt-BR/index.server.ts @@ -1,13 +1,14 @@ import { defineHandler, defineHead, type InferProps } from 'void' -import { loadSponsors } from '../../lib/landing/load-sponsors.ts' +import { getCachedSponsors } from '../../lib/landing/get-sponsors.ts' +import type { KVStore } from '../../lib/sponsors-cache/store.ts' // prerender=false keeps the scoped COOP/COEP headers (void.json routing.headers // `/pt-BR`) applying on every request, matching `/en`. export const prerender = false -export const loader = defineHandler(async () => ({ - sponsors: await loadSponsors(), +export const loader = defineHandler(async (c) => ({ + sponsors: await getCachedSponsors((c.env as unknown as { KV?: KVStore }).KV), })) export type Props = InferProps diff --git a/public/fonts/Manrope_500Medium.ttf b/public/fonts/Manrope_500Medium.ttf new file mode 100644 index 00000000..c6d28def Binary files /dev/null and b/public/fonts/Manrope_500Medium.ttf differ diff --git a/public/fonts/Manrope_700Bold.ttf b/public/fonts/Manrope_700Bold.ttf new file mode 100644 index 00000000..62a61839 Binary files /dev/null and b/public/fonts/Manrope_700Bold.ttf differ diff --git a/routes/sponsors.png.ts b/routes/sponsors.png.ts new file mode 100644 index 00000000..ddf0a8dc --- /dev/null +++ b/routes/sponsors.png.ts @@ -0,0 +1,86 @@ +// routes/sponsors.png.ts +// GET /sponsors.png — the sponsor wall as a PNG (works everywhere: GitHub, npm, +// crates.io). Serves the cron/webhook-warmed blob from R2 via the KV manifest; +// on a cold cache miss it renders this one from the cached sponsor list (KV +// snapshot first, live GitHub only when KV has none) AND kicks off a background +// refresh (force) that warms all 4 blobs (svg/png x light/dark) for next time. +import { defineHandler } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { getCachedSponsors } from '../lib/landing/get-sponsors.ts' +import { parseTheme } from '../lib/sponsors-image/theme.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { ensureYoga } from '../lib/sponsors-image/card.ts' +import { ensureResvg } from '../lib/sponsors-image/resvg.ts' +import { renderSponsorsImage } from '../lib/sponsors-image/render.ts' +import { + readImage, + type KVStore, + type R2Store, +} from '../lib/sponsors-cache/store.ts' +import { refreshSponsorsCache } from '../lib/sponsors-cache/refresh.ts' + +const CACHE_CONTROL = + 'public, s-maxage=600, max-age=600, stale-while-revalidate=86400' + +export const GET = defineHandler(async (c) => { + const theme = parseTheme(c.req.query('theme')) + const e = c.env as unknown as { + KV?: KVStore + STORAGE?: R2Store + ASSETS: AssetsFetcher + } + + // Warm path: serve the cron/webhook-rendered blob straight from R2. + if (e.KV && e.STORAGE) { + const cached = await readImage(e.KV, e.STORAGE, 'png', theme) + if (cached) { + return new Response(cached.body as BodyInit, { + headers: { + 'Content-Type': cached.contentType, + 'Cache-Control': CACHE_CONTROL, + }, + }) + } + } + + // Cold miss: render THIS request from the cached list, then warm all 4 blobs in + // the background. KV-first (not a bare live fetch) so a GitHub outage can't make + // us serve — and CDN-cache — an empty wall while good sponsor data sits in KV. + await ensureYoga(yogaWasm) + await ensureResvg(resvgWasm) + const sponsors = await getCachedSponsors(e.KV) + const fonts = await loadFonts((path) => readAsset(e.ASSETS, c.req.url, path)) + const { body, contentType } = await renderSponsorsImage({ + format: 'png', + theme, + sponsors, + fonts, + }) + if (e.KV && e.STORAGE) { + const kv = e.KV + const r2 = e.STORAGE + c.executionCtx.waitUntil( + refreshSponsorsCache({ + kv, + r2, + // Reuse the data we just rendered from — a second bypassed fetch could + // degrade to empty; the refresh's isEmpty guard already refuses to + // publish empty, and reusing keeps the served image and warmed blob in + // sync. + loadFresh: () => Promise.resolve(sponsors), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)), + yogaWasm, + resvgWasm, + force: true, + }), + ) + } + return new Response(body as BodyInit, { + headers: { 'Content-Type': contentType, 'Cache-Control': CACHE_CONTROL }, + }) +}) diff --git a/routes/sponsors.svg.ts b/routes/sponsors.svg.ts new file mode 100644 index 00000000..6493aad8 --- /dev/null +++ b/routes/sponsors.svg.ts @@ -0,0 +1,85 @@ +// routes/sponsors.svg.ts +// GET /sponsors.svg — the sponsor wall as a self-contained SVG (avatars inlined, +// Camo-safe) for GitHub READMEs. ?theme=light|dark (default light). Serves the +// cron/webhook-warmed blob from R2 via the KV manifest; on a cold cache miss it +// renders this one from the cached sponsor list (KV snapshot first, live GitHub +// only when KV has none) AND kicks off a background refresh (force) that warms +// all 4 blobs (svg/png x light/dark) for next time. +import { defineHandler } from 'void' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { getCachedSponsors } from '../lib/landing/get-sponsors.ts' +import { parseTheme } from '../lib/sponsors-image/theme.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../lib/sponsors-image/fonts.ts' +import { ensureYoga } from '../lib/sponsors-image/card.ts' +import { renderSponsorsImage } from '../lib/sponsors-image/render.ts' +import { + readImage, + type KVStore, + type R2Store, +} from '../lib/sponsors-cache/store.ts' +import { refreshSponsorsCache } from '../lib/sponsors-cache/refresh.ts' + +const CACHE_CONTROL = + 'public, s-maxage=600, max-age=600, stale-while-revalidate=86400' + +export const GET = defineHandler(async (c) => { + const theme = parseTheme(c.req.query('theme')) + const e = c.env as unknown as { + KV?: KVStore + STORAGE?: R2Store + ASSETS: AssetsFetcher + } + + // Warm path: serve the cron/webhook-rendered blob straight from R2. + if (e.KV && e.STORAGE) { + const cached = await readImage(e.KV, e.STORAGE, 'svg', theme) + if (cached) { + return new Response(cached.body as BodyInit, { + headers: { + 'Content-Type': cached.contentType, + 'Cache-Control': CACHE_CONTROL, + }, + }) + } + } + + // Cold miss: render THIS request from the cached list, then warm all 4 blobs in + // the background. KV-first (not a bare live fetch) so a GitHub outage can't make + // us serve — and CDN-cache — an empty wall while good sponsor data sits in KV. + await ensureYoga(yogaWasm) + const sponsors = await getCachedSponsors(e.KV) + const fonts = await loadFonts((path) => readAsset(e.ASSETS, c.req.url, path)) + const { body, contentType } = await renderSponsorsImage({ + format: 'svg', + theme, + sponsors, + fonts, + }) + if (e.KV && e.STORAGE) { + const kv = e.KV + const r2 = e.STORAGE + c.executionCtx.waitUntil( + refreshSponsorsCache({ + kv, + r2, + // Reuse the data we just rendered from — a second bypassed fetch could + // degrade to empty; the refresh's isEmpty guard already refuses to + // publish empty, and reusing keeps the served image and warmed blob in + // sync. + loadFresh: () => Promise.resolve(sponsors), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)), + yogaWasm, + resvgWasm, + force: true, + }), + ) + } + return new Response(body as BodyInit, { + headers: { 'Content-Type': contentType, 'Cache-Control': CACHE_CONTROL }, + }) +}) diff --git a/routes/webhooks/github-sponsors.ts b/routes/webhooks/github-sponsors.ts new file mode 100644 index 00000000..39008f47 --- /dev/null +++ b/routes/webhooks/github-sponsors.ts @@ -0,0 +1,50 @@ +// routes/webhooks/github-sponsors.ts +// POST /webhooks/github-sponsors — GitHub Sponsors webhook receiver. Verifies the +// HMAC signature over the raw body, then refreshes the cache in the background so +// we return 2xx well within GitHub's 10s deadline. +import { defineHandler } from 'void' +import { env } from 'void/env' +import yogaWasm from 'satori/yoga.wasm' +import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm' +import { verifyGithubSignature } from '../../lib/sponsors-cache/signature.ts' +import { loadSponsors } from '../../lib/landing/load-sponsors.ts' +import { + loadFonts, + readAsset, + type AssetsFetcher, +} from '../../lib/sponsors-image/fonts.ts' +import { refreshSponsorsCache } from '../../lib/sponsors-cache/refresh.ts' +import type { KVStore, R2Store } from '../../lib/sponsors-cache/store.ts' + +export const POST = defineHandler(async (c) => { + const secret = env.GITHUB_SPONSORS_WEBHOOK_SECRET + if (!secret) return c.text('webhook not configured', 503) + + const raw = await c.req.text() + const valid = await verifyGithubSignature( + secret, + raw, + c.req.header('x-hub-signature-256'), + ) + if (!valid) return c.text('invalid signature', 401) + + if (c.req.header('x-github-event') !== 'sponsorship') return c.body(null, 204) + + const e = c.env as unknown as { + KV: KVStore + STORAGE: R2Store + ASSETS: AssetsFetcher + } + c.executionCtx.waitUntil( + refreshSponsorsCache({ + kv: e.KV, + r2: e.STORAGE, + loadFresh: () => loadSponsors({ bypassCache: true }), + loadFonts: () => loadFonts((p) => readAsset(e.ASSETS, c.req.url, p)), + yogaWasm, + resvgWasm, + force: false, + }), + ) + return c.body(null, 202) +}) diff --git a/void.json b/void.json index 31f37b51..2be8271e 100644 --- a/void.json +++ b/void.json @@ -70,8 +70,8 @@ "inference": { "bindings": { "db": false, - "kv": false, - "storage": false, + "kv": true, + "storage": true, "ai": false } }, diff --git a/yarn.lock b/yarn.lock index 3d8ce428..f304427c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2834,6 +2834,13 @@ __metadata: languageName: node linkType: hard +"@resvg/resvg-wasm@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-wasm@npm:2.6.2" + checksum: 10c0/74eb061c53510dad516e42375d1802a5b70ff531abd3c858921178add5acd136382a2f0afc1bd83fa72ec2c2c9bd40a636000572a152b53a9b04e9c131ba0223 + languageName: node + linkType: hard + "@rolldown/binding-android-arm64@npm:1.1.4": version: 1.1.4 resolution: "@rolldown/binding-android-arm64@npm:1.1.4" @@ -6958,6 +6965,7 @@ __metadata: "@radix-ui/react-progress": "npm:^1.1.11" "@radix-ui/react-slot": "npm:^1.3.0" "@resvg/resvg-js": "npm:^2.6.2" + "@resvg/resvg-wasm": "npm:2.6.2" "@tailwindcss/postcss": "npm:^4.3.2" "@types/node": "npm:^24.0.0" "@types/postcss-functions": "npm:^4"