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:
parent
7b9ac5dd18
commit
79f3bee8a9
15 changed files with 66 additions and 74 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
35
web/src/lib/replay-data.ts
Normal file
35
web/src/lib/replay-data.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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 ')}`,
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">⚔</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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue