Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
64ca27f
fix(web): align /internal/* result cache with the public routes (#143)
Aug 17, 2026
837ec79
fix(web): make /internal follow the shared slot's cache policy
Aug 17, 2026
d5d9594
fix(web): don't block the OG render on a revalidation; normalise the …
Aug 19, 2026
2deac84
fix(web): don't let a shared back-off marker hand the crawler a place…
Aug 20, 2026
2e85c32
fix(web): guard the opaque `null` origin; correct the preview cache c…
Aug 20, 2026
df3f20b
fix(web): bound the SWR window and keep a background refresh off the …
Aug 20, 2026
3b0886e
fix(web): collapse concurrent SWR background refreshes onto one lookup
Aug 20, 2026
3f74b7e
fix(web): bound EVERY stale exit for a consumer that pins the answer
Aug 20, 2026
780df8d
fix(web): never hand a pinning consumer a cached partial
Aug 20, 2026
fd09b1b
fix(web): exempt terminal answers from the pin bound; 503 a computed …
Aug 20, 2026
5896333
fix(web): throttle the partial 503, refuse gallop-only partials, boun…
Aug 24, 2026
66b4c5c
fix(web): drop the unreachable SWR path, cap a pinned partial's TTL
Aug 27, 2026
317ca2b
docs(web): correct two rationales that read as adequacy (#156, #157)
Aug 27, 2026
5b1b72d
fix(web): keep /internal off badge's flight; fix two guards that coul…
Aug 27, 2026
a679bfc
fix(web): scope the partial throttle to this caller; guard the shippe…
Aug 27, 2026
cb23da8
fix(web): bind the :pinpartial marker to the entry it vouches for
Aug 27, 2026
a90f538
fix(web): order the :pinpartial marker by stamp, not by two read-time…
Aug 27, 2026
ea541fe
fix(web): keep the shared slot's TTL and back-off clock caller-indepe…
Aug 27, 2026
ef2bde6
fix(web): recompute an unstamped marker/entry pair; correct two stale…
Aug 27, 2026
5bc4e13
docs(web): scope the key-alignment claim to issue/PR; correct a stale…
Aug 27, 2026
70a1640
fix(web): warn when /internal has no routable cache origin
Aug 28, 2026
7da69e4
fix(web): warn when a cache-origin var is set but rejected
Aug 28, 2026
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ pnpm --filter @released/web-og dev # wrangler dev for web-og
pnpm --filter git-released dev -- <input> # tsx-run the CLI in place
```

Working on OG rendering locally? Put this in `packages/web/.dev.vars`:

```
PUBLIC_BASE_URL=http://localhost:8787
```

`/internal/*` (what web-og calls) keys the result cache on the deployment's own
public origin. `wrangler dev` loads `[vars]`, where `PROD_HOST` is set, so
without the override the OG path keys on `https://released.blabberate.com` while
your public routes key on `http://localhost:8787`: two namespaces on one
machine, and every local OG request pays a full lookup instead of hitting the
slot the permalink just warmed. Prod and the preview Worker set it themselves.

### Local checks reference

| Command | What it does | Gate? |
Expand Down
19 changes: 13 additions & 6 deletions packages/web/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@

import type { CacheStore } from '@released/core';

/** A cached value plus how long ago it was written (seconds). */
export type CacheEntry<T> = { value: T; ageSeconds: number };
/** A cached value, how long ago it was written (seconds), and the raw
* `x-cached-at` wall-clock stamp it was derived from (null for an entry
* written before this header existed).
*
* `ageSeconds` is floored from a `Date.now()` sample taken at READ time, so two
* entries read one Cache API round trip apart carry ages sampled at two
* different instants — comparing them can invert the true write order when the
* floors straddle a second boundary. `stampedAt` is the write-time instant
* itself, so an ordering test between two entries is exact. */
export type CacheEntry<T> = { value: T; ageSeconds: number; stampedAt: number | null };

export type WorkerCache = CacheStore & {
/** Like get(), but also reports the entry's age so callers can judge staleness. */
Expand Down Expand Up @@ -53,11 +61,10 @@ export function makeWorkerCache(req: Request, ttlSecondsDefault = 1800): WorkerC
return null;
}
const stamped = Number(res.headers.get('x-cached-at'));
const stampedAt = Number.isFinite(stamped) && stamped > 0 ? stamped : null;
const ageSeconds =
Number.isFinite(stamped) && stamped > 0
? Math.max(0, Math.floor((Date.now() - stamped) / 1000))
: 0;
return { value, ageSeconds };
stampedAt === null ? 0 : Math.max(0, Math.floor((Date.now() - stampedAt) / 1000));
return { value, ageSeconds, stampedAt };
},

async put<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
Expand Down
309 changes: 291 additions & 18 deletions packages/web/src/resolve.ts

Large diffs are not rendered by default.

308 changes: 277 additions & 31 deletions packages/web/src/routes/internal.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/web/test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ describe('makeWorkerCache', () => {
expect(entry?.value).toEqual({ x: 7 });
expect(entry?.ageSeconds).toBeGreaterThanOrEqual(41);
expect(entry?.ageSeconds).toBeLessThanOrEqual(44);
// The raw write-time stamp is reported too: `ageSeconds` is floored from a
// read-time `Date.now()`, so an ordering test between two entries read at
// different instants needs the stamp itself (resolve.ts writtenNoEarlierThan).
expect(entry?.stampedAt).toBe(cachedAt);
});

it('getEntry returns null when there is no cached entry', async () => {
Expand All @@ -127,5 +131,8 @@ describe('makeWorkerCache', () => {
const cache = makeWorkerCache(new Request('https://released-web.lukaso.workers.dev/'));
const entry = await cache.getEntry('k');
expect(entry?.ageSeconds).toBe(0);
// ...and reports the stamp as absent rather than inventing one, so an
// ordering test falls back to ages instead of comparing against a fake 0.
Comment thread
lukaso-bot marked this conversation as resolved.
Outdated
expect(entry?.stampedAt).toBeNull();
});
});
17 changes: 9 additions & 8 deletions packages/web/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const INTERNAL_SECRET = 'test-shared-secret';
// typed `unknown`, not LookupResult (a full LookupResult isn't required to exercise
// the route, and the seeded fixtures below don't populate one).
async function seedFederatedResult(sha: string, result: unknown) {
const key = await cacheKey('res', 'gitlab.gnome.org/GNOME/gimp', `sha:${sha}`);
const key = await cacheKey('res', 'gitlab.gnome.org/GNOME/gimp', `sha:${sha}`, 'cull', 'nopre');
cacheStore.set(
`https://released.example/__cache__/${encodeURIComponent(key)}`,
new Response(JSON.stringify(result), { headers: { 'content-type': 'application/json' } }),
Expand Down Expand Up @@ -600,9 +600,10 @@ describe('web Worker — issue/PR internal endpoints (#79)', () => {
releaseNotesHtml: null,
rateLimit: null,
};
// Seed the slot the route reads: cacheKey('res', `${host}/${projectPath}`,
// `issue:${number}`) — mirroring the commit endpoint's `sha:${sha}` key.
const key = await cacheKey('res', 'github.com/honojs/hono', 'issue:11');
// Seed the slot the route reads. Since #143 that is the SAME key the public
// /i/ permalink writes: cacheKey('res', `${host}/${projectPath}`,
// `issue#${number}`, 'cull', 'nopre') — see routes/internal.ts.
const key = await cacheKey('res', 'github.com/honojs/hono', 'issue#11', 'cull', 'nopre');
cacheStore.set(
`https://released.example/__cache__/${encodeURIComponent(key)}`,
new Response(JSON.stringify(seeded), { headers: { 'content-type': 'application/json' } }),
Expand Down Expand Up @@ -631,7 +632,7 @@ describe('web Worker — issue/PR internal endpoints (#79)', () => {
releaseNotesHtml: null,
rateLimit: null,
};
const key = await cacheKey('res', 'github.com/honojs/hono', 'pr:17');
const key = await cacheKey('res', 'github.com/honojs/hono', 'pr#17', 'cull', 'nopre');
cacheStore.set(
`https://released.example/__cache__/${encodeURIComponent(key)}`,
new Response(JSON.stringify(seeded), { headers: { 'content-type': 'application/json' } }),
Expand Down Expand Up @@ -664,7 +665,7 @@ describe('web Worker — issue/PR internal endpoints (#79)', () => {
rateLimit: null,
};
// Proves the route keys the cache by host on the (Hono-decoded) projectPath.
const key = await cacheKey('res', 'gitlab.gnome.org/GNOME/glib', 'issue:1234');
const key = await cacheKey('res', 'gitlab.gnome.org/GNOME/glib', 'issue#1234', 'cull', 'nopre');
cacheStore.set(
`https://released.example/__cache__/${encodeURIComponent(key)}`,
new Response(JSON.stringify(seeded), { headers: { 'content-type': 'application/json' } }),
Expand Down Expand Up @@ -705,7 +706,7 @@ describe('web Worker — issue/PR internal endpoints (#79)', () => {
rateLimit: null,
};
// Seed the slot the FIXED route keys on (Hono already decoded bad%25 → bad%).
const key = await cacheKey('res', 'gitlab.gnome.org/bad%', 'issue:1');
const key = await cacheKey('res', 'gitlab.gnome.org/bad%', 'issue#1', 'cull', 'nopre');
cacheStore.set(
`https://released.example/__cache__/${encodeURIComponent(key)}`,
new Response(JSON.stringify(seeded), { headers: { 'content-type': 'application/json' } }),
Expand All @@ -721,7 +722,7 @@ describe('web Worker — issue/PR internal endpoints (#79)', () => {

it('GET /internal/issue/... fails CLOSED when INTERNAL_SECRET is unset (no web-og fallback)', async () => {
cacheStore.clear();
const key = await cacheKey('res', 'github.com/honojs/hono', 'issue:11');
const key = await cacheKey('res', 'github.com/honojs/hono', 'issue#11', 'cull', 'nopre');
cacheStore.set(
`https://released.example/__cache__/${encodeURIComponent(key)}`,
new Response(
Expand Down
Loading
Loading