fix(web): serve replays from same-origin /data/ static assets, not R2

Replays are bundled into the Pages deploy as gzipped static assets (B2 stays
the private cold archive). Repoint all replay/card/thumbnail/live.json fetches
off the empty R2 cache and the non-resolving b2.aicodebattle.com onto
same-origin /data/, via a shared fetchReplayFromUrl helper that gunzips
.json.gz with DecompressionStream.

- new web/src/lib/replay-data.ts (REPLAY_BASE, replayUrl, fetchReplayFromUrl)
- replay.ts / embed.ts / pages/embed.ts / playlist-carousel.ts use the helper
- og-tags, bot-card, home, matches, bot-profile, playlists, feedback, ambient,
  api-types: /r2/ -> /data/
- pages.json data_paths updated; friendlier 404 message preserved
- 21 web tests pass; npm run build OK

Closes: bf-5cwi

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-26 16:41:30 -04:00
parent 7b9ac5dd18
commit 79f3bee8a9
15 changed files with 66 additions and 74 deletions

View file

@ -14,8 +14,8 @@
"replay_viewer": "replay.html"
},
"data_paths": {
"pages_data": "https://aicodebattle.com/data",
"r2_data": "https://r2.aicodebattle.com"
"pages_data": "/data",
"replay_data": "/data/replays (gzipped static assets bundled into the deploy by the index builder)"
},
"deployment": {
"method": "wrangler pages deploy",

View file

@ -241,13 +241,13 @@ export async function registerBot(request: RegisterRequest): Promise<RegisterRes
return response.json();
}
// R2_BASE_URL is the Cloudflare R2 bucket custom domain for live data.
// The evolver writes live.json here every cycle with Cache-Control: max-age=10.
const R2_BASE_URL = '/r2';
// Evolution live data is bundled into the Pages deploy under /data/ by the index builder.
// The evolver writes live.json every cycle with Cache-Control: max-age=10.
const DATA_BASE_URL = '/data';
export async function fetchEvolutionData(): Promise<EvolutionLiveData> {
// Evolution data changes every ~10s — bypass SWR, always fetch fresh
const response = await fetch(`${R2_BASE_URL}/evolution/live.json`);
const response = await fetch(`${DATA_BASE_URL}/evolution/live.json`);
if (!response.ok) throw new Error(`Failed to fetch evolution data: ${response.status}`);
return response.json();
}

View file

@ -392,7 +392,7 @@ function wrapText(
* Generate a shareable bot card URL
*/
export function getBotCardURL(botId: string): string {
return `/r2/cards/${botId}.png`;
return `/data/cards/${botId}.png`;
}
/**

View file

@ -4,6 +4,7 @@
import type { Playlist, PlaylistMatch } from '../api-types';
import type { Replay } from '../types';
import { fetchReplayFromUrl, replayUrl } from '../lib/replay-data';
import {
computeAllDensities,
computeSpeedSchedule,
@ -43,8 +44,6 @@ export interface CarouselOptions {
const DEFAULT_AUTO_ADVANCE_DELAY = 3000;
const METADATA_PANEL_WIDTH = 280;
const TRANSITION_MS = 300;
const R2_BASE = '/r2';
const B2_FALLBACK = 'https://b2.aicodebattle.com';
const SWIPE_THRESHOLD = 50; // min px to trigger advance
const VELOCITY_THRESHOLD = 0.3; // px/ms — fast flick triggers even below threshold
const REDUCED_MOTION = typeof window !== 'undefined'
@ -373,19 +372,7 @@ export class PlaylistCarousel {
}
private async fetchReplay(matchId: string): Promise<Replay> {
const urls = [
`${R2_BASE}/replays/${matchId}.json.gz`,
`${B2_FALLBACK}/replays/${matchId}.json.gz`,
];
for (const url of urls) {
try {
const resp = await fetch(url);
if (resp.ok) return await resp.json();
} catch { /* try next */ }
}
const resp = await fetch(`/replays/${matchId}.json.gz`);
if (!resp.ok) throw new Error(`Failed to fetch replay ${matchId}`);
return resp.json();
return fetchReplayFromUrl(replayUrl(matchId));
}
private preloadNext(index: number): void {
@ -435,7 +422,7 @@ export class PlaylistCarousel {
btn.addEventListener('click', () => {
const id = (btn as HTMLElement).dataset.matchId!;
this.destroy();
window.location.hash = `/watch/replay?url=/replays/${id}.json.gz`;
window.location.hash = `/watch/replay?url=${replayUrl(id)}`;
});
}
}

View file

@ -2,6 +2,7 @@
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
import { fetchCommentary } from './api-types';
import { fetchReplayFromUrl, replayUrl } from './lib/replay-data';
// Player colors matching replay-viewer.ts
const PLAYER_COLORS = [
@ -14,8 +15,6 @@ const PLAYER_COLORS = [
];
// Configuration
const R2_BASE = '/r2';
const B2_BASE = 'https://b2.aicodebattle.com';
const PAGES_BASE = 'https://ai-code-battle.pages.dev';
interface EmbedConfig {
@ -178,28 +177,8 @@ class EmbedViewer {
}
private async fetchReplay(matchId: string): Promise<Replay> {
// Try R2 warm cache first
const r2Url = `${R2_BASE}/replays/${matchId}.json.gz`;
try {
const response = await fetch(r2Url);
if (response.ok) {
// Note: For gzipped content, browser handles decompression automatically
// if Content-Encoding: gzip is set, or we can use DecompressionStream
const replay = await response.json();
return replay as Replay;
}
} catch (e) {
console.warn('R2 fetch failed, trying B2:', e);
}
// Fall back to B2 cold archive
const b2Url = `${B2_BASE}/replays/${matchId}.json.gz`;
const response = await fetch(b2Url);
if (!response.ok) {
throw new Error(`Replay not found: ${matchId}`);
}
const replay = await response.json();
return replay as Replay;
// Replays are gzipped static assets bundled into the Pages deploy under /data/replays/.
return fetchReplayFromUrl(replayUrl(matchId));
}
private async fetchDemoReplay(): Promise<Replay> {

View file

@ -223,7 +223,7 @@ export function startAmbientPolling(intervalMs = 30_000): void {
try {
// Fetch evolution live data for generation changes
const evoResp = await fetch(
'/r2/evolution/live.json',
'/data/evolution/live.json',
);
if (evoResp.ok) {
const evoData: LiveJSON = await evoResp.json();

View file

@ -0,0 +1,35 @@
// Replay data access.
//
// Replays are served as gzipped static assets under /data/replays/ on Cloudflare
// Pages: B2 is the private cold archive, and the index-builder bundles the warm
// set into the Pages deploy (cmd/acb-index-builder bundleWarmReplays). Because
// Pages serves the .json.gz bytes verbatim (no Content-Encoding), the browser
// must gunzip them with DecompressionStream.
import type { Replay } from '../types';
/** Base path for replay assets bundled into the Pages deploy. */
export const REPLAY_BASE = '/data/replays';
/** Same-origin URL for a replay by match ID. */
export function replayUrl(matchId: string): string {
return `${REPLAY_BASE}/${matchId}.json.gz`;
}
/**
* Fetch and parse a replay from a URL, transparently gunzipping .gz responses.
* Throws `Error("HTTP <status>")` on a non-OK response so callers can branch on 404.
*/
export async function fetchReplayFromUrl(url: string): Promise<Replay> {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (url.endsWith('.gz') && resp.body && typeof DecompressionStream !== 'undefined') {
const stream = resp.body.pipeThrough(new DecompressionStream('gzip'));
return JSON.parse(await new Response(stream).text()) as Replay;
}
return (await resp.json()) as Replay;
}
/** Fetch a replay by match ID from the bundled Pages assets. */
export function fetchReplay(matchId: string): Promise<Replay> {
return fetchReplayFromUrl(replayUrl(matchId));
}

View file

@ -68,7 +68,7 @@ export function getBotProfileOGTags(bot: {
win_rate: number;
evolved?: boolean;
}): OGTags {
const cardUrl = `/r2/cards/${bot.id}.png`;
const cardUrl = `/data/cards/${bot.id}.png`;
return {
title: `${bot.name} - Bot Profile`,
@ -89,7 +89,7 @@ export function getReplayOGTags(match: {
}): OGTags {
const winner = match.participants.find(p => p.won);
const winnerName = winner ? winner.name : 'Draw';
const thumbnailUrl = `/r2/thumbnails/${match.id}.png`;
const thumbnailUrl = `/data/thumbnails/${match.id}.png`;
return {
title: `Match: ${match.participants.map(p => p.name).join(' vs ')}`,

View file

@ -204,7 +204,7 @@ function renderMatchItem(match: BotProfile['recent_matches'][number]): string {
<span class="match-opponent">${opponent ? escapeHtml(opponent.name) : 'Unknown'}</span>
<span class="match-score">${match.participants.map(p => p.score).join(' - ')}</span>
${enrichedBadge}
<a href="#/watch/replay?url=/r2/replays/${match.id}.json.gz" class="btn small">Watch</a>
<a href="#/watch/replay?url=/data/replays/${match.id}.json.gz" class="btn small">Watch</a>
</div>
`;
}
@ -232,7 +232,7 @@ function renderRivalsSection(rivalries: RivalryEntry[], botId: string): string {
<span class="rivalry-record">${botWins}-${opponentWins}${r.record.draws > 0 ? `-${r.record.draws}` : ''}</span>
<span class="rivalry-winrate">${winPct}% win rate</span>
</div>
${r.closest_match ? `<a href="#/watch/replay?url=/r2/replays/${r.closest_match}.json.gz" class="btn small secondary">Watch closest match</a>` : ''}
${r.closest_match ? `<a href="#/watch/replay?url=/data/replays/${r.closest_match}.json.gz" class="btn small secondary">Watch closest match</a>` : ''}
</div>
`;
}).join('');

View file

@ -1,5 +1,6 @@
// Embeddable replay viewer - minimal chrome for iframe embedding
// §13.4: /embed/{id} - auto-play, ~50KB, Open Graph tags
import { fetchReplayFromUrl } from '../lib/replay-data';
const loadReplayViewer = () => import('../replay-viewer');
@ -37,7 +38,7 @@ export function renderEmbedPage(params: Record<string, string>): void {
}
loadReplayViewer().then(({ ReplayViewer }) => {
const replayUrl = params.id ? `/r2/replays/${params.id}.json.gz` : undefined;
const replayUrl = params.id ? `/data/replays/${params.id}.json.gz` : undefined;
if (!replayUrl) {
const noReplay = document.getElementById('no-replay');
if (noReplay) noReplay.textContent = 'No replay specified';
@ -69,12 +70,8 @@ export function renderEmbedPage(params: Record<string, string>): void {
}
};
// Load replay
fetch(replayUrl)
.then(resp => {
if (!resp.ok) throw new Error(`Failed to load replay: ${resp.status}`);
return resp.json();
})
// Load replay (gzipped static asset → fetchReplayFromUrl gunzips it)
fetchReplayFromUrl(replayUrl)
.then((replay: any) => {
viewer.loadReplay(replay);
viewer.setTurn(startTurn);

View file

@ -401,7 +401,7 @@ function initFeedback(): void {
// ─── Utilities ────────────────────────────────────────────────────────────────
function replayUrlForMatch(m: MatchSummary): string {
return `/r2/replays/${m.id}.json.gz`;
return `/data/replays/${m.id}.json.gz`;
}
function formatDate(s: string | null): string {

View file

@ -88,7 +88,7 @@ function renderPlaylistCards(playlists: any[]): string {
<a href="#/watch/playlists/${pl.slug}" class="home-pl-card">
<div class="home-pl-thumb">
${pl.thumbnail_match_id
? `<img src="/r2/thumbnails/${pl.thumbnail_match_id}.png" alt="${esc(pl.title)}" loading="lazy">`
? `<img src="/data/thumbnails/${pl.thumbnail_match_id}.png" alt="${esc(pl.title)}" loading="lazy">`
: '<div class="home-pl-placeholder">&#9876;</div>'}
</div>
<div class="home-pl-info">
@ -178,7 +178,7 @@ export async function renderHomePage(): Promise<void> {
? `${featuredReplay!.participants.map((p) => `<strong>${esc(p.name)}</strong>`).join(' vs ')}${featuredReplay!.winner_id ? ` — Winner: <strong>${esc(featuredReplay!.participants.find((p) => p.bot_id === featuredReplay!.winner_id)?.name || 'Unknown')}</strong>` : ''}`
: 'Demo Replay — Watch a sample battle';
const replayLink = hasLiveReplay
? `#/watch/replay?url=/r2/replays/${featuredReplay!.id}.json.gz`
? `#/watch/replay?url=/data/replays/${featuredReplay!.id}.json.gz`
: '#/watch/replays';
// Build lazy-loaded content for below-the-fold sections

View file

@ -319,7 +319,7 @@ function renderMatchCard(match: MatchSummary): string {
<span class="match-reason">${match.end_reason ?? '-'}</span>
${match.map_id ? `<span class="match-map">Map: ${escapeHtml(match.map_id)}</span>` : ''}
</div>
<a href="#/watch/replay?url=/r2/replays/${match.id}.json.gz" class="btn small">Watch Replay</a>
<a href="#/watch/replay?url=/data/replays/${match.id}.json.gz" class="btn small">Watch Replay</a>
</div>
</div>
`;

View file

@ -532,7 +532,7 @@ function addMatchShowMore(container: HTMLElement, remaining: PlaylistMatch[]): v
}
function watchMatch(matchId: string): void {
window.location.hash = `/watch/replay?url=/r2/replays/${matchId}.json.gz`;
window.location.hash = `/watch/replay?url=/data/replays/${matchId}.json.gz`;
}
function copyEmbedCode(matchId: string): void {

View file

@ -28,6 +28,7 @@ import {
import { THEATER_STYLES, TheaterMode } from '../components/theater';
import { setActiveReplay } from '../components/pip-registry';
import { getPipMatchId, restorePip } from '../components/pip';
import { fetchReplayFromUrl } from '../lib/replay-data';
const loadReplayViewer = () => import('../replay-viewer');
@ -46,7 +47,7 @@ export function renderReplayPage(params: Record<string, string>): void {
loadReplayViewer().then(({ ReplayViewer }) => {
// If params.url is not set but params.id is, construct the URL from the match ID
const replayUrl = params.url || (params.id ? `/r2/replays/${params.id}.json.gz` : undefined);
const replayUrl = params.url || (params.id ? `/data/replays/${params.id}.json.gz` : undefined);
initReplayViewerWithClass(ReplayViewer, replayUrl);
});
}
@ -1397,18 +1398,11 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
const url = urlInput.value.trim();
if (!url) return;
try {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new Error('HTTP_404');
}
throw new Error(`HTTP ${response.status}`);
}
const replay = await response.json() as Replay;
const replay = await fetchReplayFromUrl(url);
loadReplay(replay);
} catch (err) {
const errMsg = String(err);
if (errMsg.includes('HTTP_404')) {
if (errMsg.includes('HTTP 404')) {
showLoadError('This replay is not available yet — it may not have been uploaded.', url);
} else if (errMsg.includes('HTTP')) {
showLoadError(`Could not load this replay: ${errMsg}`, url);