Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions packages/web-og/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
// GET /h/:host/r/:projectPath/c/:sha.png — federated (any host, #8)
// → Fetch the result data from `web` via Service Binding (D23).
// → Render PNG via @cloudflare/workers-og (Satori + resvg-wasm).
// → Cache 24h. On data miss, render a neutral placeholder with short TTL
// (never a long-cached error).
// → Cache 24h, but only for a SETTLED answer (a released commit). A
// not-yet-released or partial result, and a data miss (neutral
// placeholder), get a short TTL so the card can still flip (#151).

import { type LookupResult, OG_TEMPLATE_VERSION } from '@released/core';
import { type Context, Hono } from 'hono';
Expand Down Expand Up @@ -179,15 +180,62 @@ export default app;
// of the og.* zone) recompress the PNG that `web` links as the byte-exact social
// card. Everything after it is the actual freshness policy.
export const LONG_CACHE = `public, no-transform, max-age=${24 * 60 * 60}, s-maxage=${24 * 60 * 60}`;

/** A card we could NOT render from a result: the placeholder. Either `/internal`
* missed/failed (transient — retry soon), or the URL carries a template version
* this build cannot render (self-heals once web-og lands). Both want the
* shortest honest retry window, so this stays at 60s. */
export const SHORT_CACHE = 'public, no-transform, max-age=60';
Comment thread
lukaso-bot marked this conversation as resolved.

/** A card we DID render, from an answer that is still in motion: not-yet-released
* or a soft-deadline `partial`. Distinct from SHORT_CACHE because the question is
* different — not "how fast should a failure retry" but "how fast can this answer
* actually change". It cannot change faster than the data behind it, and
* `/internal` stores every computed result for 30 minutes
* (`cache.put(k, r, 30 * 60)`, packages/web/src/routes/internal.ts). A 60s TTL
* therefore bought no freshness the upstream has: it re-ran the ~700ms
* satori+resvg wasm render up to 60x/hour per URL for byte-identical JSON. 300s
* matches the TTL `badge.ts` already uses for the same pending state, and is
* still 6x fresher than the upstream cache it reads through. */
export const PENDING_CACHE = 'public, no-transform, max-age=300, s-maxage=300';

/** True only when the answer the card renders can never change again: a
* completed traversal that found a release.
*
* The lifetime used to key on whether a result came back AT ALL, which
* long-cached two shapes that are still in motion (#151):
*
* - `firstRelease: null` renders "not yet released" — the one card whose
* whole job is to flip once a release contains the commit. Pinned for 24h,
* a commit shared an hour before its release unfurls as unreleased and
* Slack/X keep that PNG for a day after the release ships.
Comment thread
lukaso-bot marked this conversation as resolved.
Outdated
* - a `partial` is a best-effort answer from a traversal the soft deadline
* truncated, so its `firstRelease` is not confirmed to be the earliest one.
* It has to stay revalidatable rather than be pinned as if it were final.
*
* This is STRICTER than the web side, deliberately. `hardTtlFor()`
* (packages/web/src/resolve.ts) tests `firstRelease` first, so a partial
* that carries a `firstRelease` gets the 30-day terminal TTL there, and
* `badge.ts` long-caches the same shape for 24h. A truncated traversal that
* reported v2.0.0 when v1.9.0 was the true earliest is therefore still
* pinned on those two surfaces — the partial half of #151, tracked
* separately in #159. Here it revalidates.
*
* It is the OG analogue of the badge invariant the project already states:
* released → long cache, not-yet/checking → short cache. */
function isTerminal(result: LookupResult | null): boolean {
return result != null && result.firstRelease != null && !result.partial;
}

export function renderImage(
result: LookupResult | null,
ctx: { owner: string; repo: string; sha?: string; number?: string },
cacheOverride?: string,
): Response {
const SIZE = { width: 1200, height: 630 };
const cacheControl = cacheOverride ?? (result ? LONG_CACHE : SHORT_CACHE);
const cacheControl =
cacheOverride ??
(result == null ? SHORT_CACHE : isTerminal(result) ? LONG_CACHE : PENDING_CACHE);
Comment thread
lukaso-bot marked this conversation as resolved.

const node = result ? ResultCard(result) : PlaceholderCard(ctx);

Expand Down
95 changes: 93 additions & 2 deletions packages/web-og/test/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,11 @@ describe('web-og card content', () => {
// The SHIPPED badge and the date are gated on `firstRelease` — both gone.
expect(text).not.toContain('SHIPPED');
expect(text.some((t) => /^\d{4}-\d{2}-\d{2}$/.test(t))).toBe(false);
// A long-cache header still applies — we DID get a result, it's just unreleased.
// Pending-cached: "not yet released" is a pending state that has to flip
// when the release lands, so it is NOT long-cacheable just because a
// result came back (#151). Lifetime coverage lives in its own describe.
expect(res.headers.get('cache-control')).toBe(
'public, no-transform, max-age=86400, s-maxage=86400',
'public, no-transform, max-age=300, s-maxage=300',
);
});

Expand Down Expand Up @@ -745,3 +747,92 @@ describe('web-og issue/PR cards (#79)', () => {
expect(/[\u{D800}-\u{DFFF}]/u.test(joined)).toBe(false);
});
});

// #151: the cache lifetime keys on whether a result was RECEIVED, not on
// whether the answer it renders can still change. Both non-terminal shapes —
// "not yet released" (firstRelease null) and a soft-deadline `partial` — are
// real LookupResults, so both took the 24h cache. The not-yet card is the one
// card whose whole job is to flip when the release lands; a partial is an
// unconfirmed answer from a truncated traversal. Pinning either for a day in
// every crawler's cache is the OG analogue of the badge invariant the project
// already states (released → long, not-yet/checking → short).
describe('web-og cache lifetime keys on terminality, not presence (#151)', () => {
const LONG = 'public, no-transform, max-age=86400, s-maxage=86400';
// A pending answer is backed by `/internal`'s own 30-minute result cache
// (`cache.put(k, r, 30 * 60)` in packages/web/src/routes/internal.ts), so a
// 60s edge TTL cannot buy freshness the upstream does not have — it just
// re-runs the ~700ms satori+resvg render up to 60x/hour per URL while
// `/internal` hands back byte-identical JSON. 300s matches what badge.ts
// already uses for the same pending state and is still 6x fresher than the
// data behind it.
const PENDING = 'public, no-transform, max-age=300, s-maxage=300';
// PENDING stays separate from the placeholder's 60s SHORT_CACHE because the
// two answer different questions: SHORT_CACHE is "how fast should a FAILED
// render retry" (binding miss, unrenderable template version), PENDING is
// "how fast can this ANSWER change". Collapsing them by bumping SHORT_CACHE
// to 300 reddens the 14 existing placeholder-lifetime tests above, which is
// the guard for that direction.

const baseInput = {
kind: 'commit',
repo: { owner: 'facebook', repo: 'react', projectPath: 'facebook/react' },
sha: 'a'.repeat(40),
};
const released = { tag: 'v18.2.0', sha: 's', date: '2024-03-15T09:00:00Z', url: '' };

async function fetchCard(result: Record<string, unknown>): Promise<Response> {
return await app.fetch(
new Request('https://og.example/r/facebook/react/c/abc1234.png'),
makeEnv(new Response(JSON.stringify(result))),
);
}

it('not-yet-released result is PENDING-cached (300s) so the card flips when the release lands', async () => {
const res = await fetchCard({
input: baseInput,
canonicalSha: 'abc1234def5678',
firstRelease: null,
Comment thread
lukaso-bot marked this conversation as resolved.
Outdated
alsoIn: [],
releaseNotesHtml: null,
rateLimit: null,
});
expect(res.status).toBe(200);
// The card really does render the flippable copy — so this is the card
// whose lifetime matters, not an unrelated shape.
expect(collectText(lastRenderedNode)).toContain('not yet released');
expect(res.headers.get('cache-control')).toBe(PENDING);
});

it('partial result is PENDING-cached even though it carries a firstRelease', async () => {
const res = await fetchCard({
input: baseInput,
canonicalSha: 'abc1234def5678',
firstRelease: released,
partial: { reason: 'soft_deadline', candidatesTried: 12 },
alsoIn: [],
releaseNotesHtml: null,
rateLimit: null,
});
expect(res.status).toBe(200);
// A galloped answer under a blown soft deadline is not confirmed earliest,
// so it must stay revalidatable rather than pinned for a day.
expect(res.headers.get('cache-control')).toBe(PENDING);
});

// Complement. Without it, "short-cache everything" passes the two above and
// silently re-renders every settled card once a minute. (Proven: forcing
// isTerminal to false reddens this and 10 pre-existing long-cache tests.)
it('terminal released result keeps the LONG cache', async () => {
const res = await fetchCard({
input: baseInput,
canonicalSha: 'abc1234def5678',
firstRelease: released,
alsoIn: [],
releaseNotesHtml: null,
rateLimit: null,
});
expect(res.status).toBe(200);
expect(collectText(lastRenderedNode)).toContain('SHIPPED');
expect(res.headers.get('cache-control')).toBe(LONG);
});
});
Loading