Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 25 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 @@ -181,13 +182,34 @@ export default app;
export const LONG_CACHE = `public, no-transform, max-age=${24 * 60 * 60}, s-maxage=${24 * 60 * 60}`;
export const SHORT_CACHE = 'public, no-transform, max-age=60';
Comment thread
lukaso-bot marked this conversation as resolved.

/** 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 the same test `resolve.ts`'s `hardTtlFor()` applies on the web
* side, and the OG analogue of the badge invariant the project already
* states: released → long cache, not-yet/checking → short cache. */
Comment thread
lukaso-bot marked this conversation as resolved.
Outdated
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 ?? (isTerminal(result) ? LONG_CACHE : SHORT_CACHE);

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

Expand Down
84 changes: 80 additions & 4 deletions packages/web-og/test/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,10 +435,10 @@ 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.
expect(res.headers.get('cache-control')).toBe(
'public, no-transform, max-age=86400, s-maxage=86400',
);
// Short-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=60');
});

it('placeholder card (binding miss): shows "Looking up…" and the owner/repo label', async () => {
Expand Down Expand Up @@ -745,3 +745,79 @@ 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';
const SHORT = 'public, no-transform, max-age=60';

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 SHORT-cached so the card flips when the release lands', async () => {
const res = await fetchCard({
input: baseInput,
canonicalSha: 'abc1234def5678',
firstRelease: null,
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(SHORT);
});

it('partial result is SHORT-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(SHORT);
});

// 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