From 25a9bcc4688206693904f7fe418441f23961bbb2 Mon Sep 17 00:00:00 2001 From: Emma Jamieson-Hoare Date: Wed, 6 May 2026 10:41:55 +0100 Subject: [PATCH 1/3] feat: add tempo snapshots viewer app --- .github/workflows/main.yml | 11 + .github/workflows/pull-request.yml | 1 + apps/tempo-snapshots-viewer/README.md | 51 + apps/tempo-snapshots-viewer/package.json | 30 + apps/tempo-snapshots-viewer/src/index.ts | 2686 +++++++++++++++++++++ apps/tempo-snapshots-viewer/tsconfig.json | 20 + apps/tempo-snapshots-viewer/wrangler.json | 35 + pnpm-lock.yaml | 22 + 8 files changed, 2856 insertions(+) create mode 100644 apps/tempo-snapshots-viewer/README.md create mode 100644 apps/tempo-snapshots-viewer/package.json create mode 100644 apps/tempo-snapshots-viewer/src/index.ts create mode 100644 apps/tempo-snapshots-viewer/tsconfig.json create mode 100644 apps/tempo-snapshots-viewer/wrangler.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 15fb76fd7..481ad767c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -80,3 +80,14 @@ jobs: with: app: tokenlist environments: '[""]' + + deploy-tempo-snapshots-viewer: + name: Deploy Tempo Snapshots Viewer + needs: verify + uses: ./.github/workflows/deploy.yml + secrets: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + with: + app: tempo-snapshots-viewer + environments: '[""]' diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 9838595ec..b468de11e 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -71,6 +71,7 @@ jobs: #### other (single-env-apps) - app: og - app: perf + - app: tempo-snapshots-viewer - app: tokenlist env: NODE_ENV: production diff --git a/apps/tempo-snapshots-viewer/README.md b/apps/tempo-snapshots-viewer/README.md new file mode 100644 index 000000000..a9234f8cc --- /dev/null +++ b/apps/tempo-snapshots-viewer/README.md @@ -0,0 +1,51 @@ +# Tempo Snapshots Viewer + +Cloudflare Worker that displays Tempo snapshots stored in R2, including legacy single-archive snapshots and Reth-style v2 modular manifests across multiple Tempo networks. + +## Prerequisites + +- R2 bucket `tempo-node-snapshots` with public domain at `tempo-node-snapshots.tempoxyz.dev` +- Monorepo dependencies installed from the repository root with `pnpm install` + +## Setup + +```bash +pnpm install +pnpm --filter tempo-snapshots-viewer gen:types +``` + +Worker configuration lives in `wrangler.json`. + +## Development + +```bash +pnpm --filter tempo-snapshots-viewer dev # Local development at http://localhost:8787 +``` + +## Deployment + +```bash +pnpm --filter tempo-snapshots-viewer deploy # Deploys to snapshots.tempoxyz.dev +``` + +## Project Structure + +``` +apps/tempo-snapshots-viewer/ +├── src/ +│ └── index.ts # Main Worker logic +├── wrangler.json # Worker configuration +├── package.json # Dependencies +├── tsconfig.json # TypeScript config +├── worker-configuration.d.ts +└── README.md # This file +``` + +## How It Works + +1. The worker scans the bucket for both legacy root-level metadata files and v2 snapshot directories containing `manifest.json`. +2. It normalizes every snapshot into one API shape, groups them by Tempo network, and caches the result at the edge. +3. The UI uses the same modular snapshot experience as the Reth viewer, but adds Tempo network selection for mainnet, testnet, and moderato. +4. If a network has not published a v2 manifest yet, the UI falls back to the latest legacy archive download command for that network. + +API endpoint: `/api/snapshots` returns the normalized snapshot list without raw manifest payloads. diff --git a/apps/tempo-snapshots-viewer/package.json b/apps/tempo-snapshots-viewer/package.json new file mode 100644 index 000000000..5ad80f9a8 --- /dev/null +++ b/apps/tempo-snapshots-viewer/package.json @@ -0,0 +1,30 @@ +{ + "name": "tempo-snapshots-viewer", + "type": "module", + "private": true, + "repository": { + "type": "git", + "directory": "apps/tempo-snapshots-viewer", + "url": "https://github.com/tempoxyz/tempo-apps" + }, + "scripts": { + "build": "echo 'No build step needed for Cloudflare Worker'", + "check": "pnpm check:biome && pnpm check:types", + "check:biome": "biome check --write --unsafe", + "check:types": "tsgo --project tsconfig.json --noEmit", + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "gen:types": "wrangler types --env-interface='CloudflareBindings'", + "postinstall": "pnpm gen:types" + }, + "dependencies": { + "hono": "catalog:" + }, + "devDependencies": { + "@biomejs/biome": "catalog:", + "@cloudflare/workers-types": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/apps/tempo-snapshots-viewer/src/index.ts b/apps/tempo-snapshots-viewer/src/index.ts new file mode 100644 index 000000000..170d9b727 --- /dev/null +++ b/apps/tempo-snapshots-viewer/src/index.ts @@ -0,0 +1,2686 @@ +import { Hono } from 'hono' + +// Types +interface Env { + SNAPSHOTS: R2Bucket + R2_PUBLIC_URL: string +} + +// Legacy metadata format (single archive per snapshot) +interface LegacyMetadata { + chain_id: string + block: number + timestamp: string + image?: string + archive: string +} + +// New manifest format (modular components) +interface SingleArchive { + file: string + size: number +} + +interface ChunkedArchive { + blocks_per_file: number + total_blocks: number + chunk_sizes: number[] +} + +type ComponentManifest = SingleArchive | ChunkedArchive + +function isSingleArchive(c: ComponentManifest): c is SingleArchive { + return 'file' in c +} + +interface SnapshotManifest { + block: number + chain_id: number + storage_version: number + timestamp: number + base_url?: string + reth_version?: string + tempo_version?: string + image?: string + components: Record +} + +// Unified snapshot for the UI +interface Snapshot { + snapshotId: string + chainId: string + networkKey: string + networkName: string + block: number + timestamp: string + date: string + image: string + archiveUrl: string + archiveFile: string + metadataUrl: string + size: number + isModular: boolean + components?: SnapshotComponent[] + manifestUrl?: string + manifestKey?: string + rawManifest?: SnapshotManifest +} + +interface SnapshotComponent { + name: string + displayName: string + size: number +} + +const COMPONENT_DISPLAY_NAMES: Record = { + state: 'State (mdbx)', + headers: 'Headers', + transactions: 'Transactions', + transaction_senders: 'Senders', + receipts: 'Receipts', + account_changesets: 'Account Changesets', + storage_changesets: 'Storage Changesets', + rocksdb_indices: 'Indices', +} + +interface NetworkInfo { + chainId: string + key: string + name: string +} + +const NETWORKS: Record = { + '4217': { chainId: '4217', key: 'mainnet', name: 'Mainnet' }, + '42431': { chainId: '42431', key: 'moderato', name: 'Moderato' }, +} + +const DEFAULT_CHAIN_ID = '4217' + +interface ComponentSizes { + state: number + headers: number + transactions: number + transaction_senders: number + receipts: number + account_changesets: number + storage_changesets: number + rocksdb_indices: number +} + +type PresetSizes = Record<'minimal' | 'full' | 'archive', ComponentSizes> + +const PARIS_BLOCK = 15_537_394 + +type Distance = + | { type: 'all' } + | { type: 'none' } + | { type: 'distance'; blocks: number } + +function sizeForDistance(comp: ComponentManifest, dist: Distance): number { + if (dist.type === 'none') return 0 + if (isSingleArchive(comp)) return dist.type === 'all' ? comp.size : 0 + const totalSize = comp.chunk_sizes.reduce((a, b) => a + b, 0) + if (dist.type === 'all') return totalSize + const neededChunks = Math.ceil(dist.blocks / comp.blocks_per_file) + const chunks = comp.chunk_sizes + if (neededChunks >= chunks.length) return totalSize + let sum = 0 + for (let i = chunks.length - neededChunks; i < chunks.length; i++) { + sum += chunks[i] + } + return sum +} + +function getPresetDistances( + snapshotBlock: number, +): Record<'minimal' | 'full' | 'archive', Record> { + const all: Distance = { type: 'all' } + const none: Distance = { type: 'none' } + const d = (blocks: number): Distance => ({ type: 'distance', blocks }) + + const fullTxDistance = + snapshotBlock >= PARIS_BLOCK ? d(snapshotBlock - PARIS_BLOCK + 1) : all + + return { + archive: { + state: all, + headers: all, + transactions: all, + receipts: all, + account_changesets: all, + storage_changesets: all, + transaction_senders: all, + rocksdb_indices: all, + }, + full: { + state: all, + headers: all, + transactions: fullTxDistance, + receipts: d(10064), + account_changesets: d(10064), + storage_changesets: d(10064), + transaction_senders: none, + rocksdb_indices: none, + }, + minimal: { + state: all, + headers: all, + transactions: d(10064), + receipts: d(64), + account_changesets: d(10064), + storage_changesets: d(10064), + transaction_senders: none, + rocksdb_indices: none, + }, + } +} + +function getComponentSize(comp: ComponentManifest): number { + if (isSingleArchive(comp)) return comp.size + return comp.chunk_sizes.reduce((a, b) => a + b, 0) +} + +function bytesToGB(bytes: number): number { + if (bytes === 0) return 0 + return bytes / 1e9 +} + +function getPresetSizesFromManifest(manifest: SnapshotManifest): PresetSizes { + const emptyComponentSizes = (): ComponentSizes => ({ + state: 0, + headers: 0, + transactions: 0, + transaction_senders: 0, + receipts: 0, + account_changesets: 0, + storage_changesets: 0, + rocksdb_indices: 0, + }) + + const distances = getPresetDistances(manifest.block) + const result: PresetSizes = { + minimal: emptyComponentSizes(), + full: emptyComponentSizes(), + archive: emptyComponentSizes(), + } + + for (const preset of ['minimal', 'full', 'archive'] as const) { + for (const [name, comp] of Object.entries(manifest.components)) { + const componentName = name as keyof ComponentSizes + if (Object.hasOwn(result[preset], componentName)) { + const dist = distances[preset][componentName] || { type: 'all' } + result[preset][componentName] = bytesToGB(sizeForDistance(comp, dist)) + } + } + } + + return result +} + +function safeJsonForInlineScript(value: unknown): string { + return JSON.stringify(value).replace(/ { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function withR2Retry(label: string, fn: () => Promise): Promise { + for (let attempt = 1; attempt <= R2_MAX_RETRIES; attempt++) { + try { + return await fn() + } catch (err) { + if (attempt === R2_MAX_RETRIES || !isRetryableR2Error(err)) { + throw err + } + + console.warn( + `Transient R2 error during ${label}; retrying (${attempt + 1}/${R2_MAX_RETRIES})`, + ) + await sleep(R2_RETRY_BASE_DELAY_MS * attempt) + } + } + + throw new Error(`Exhausted retries for ${label}`) +} + +const app = new Hono<{ Bindings: Env }>() + +app.get('/api/snapshots', (context) => handleAPI(context.req.raw, context.env)) +app.get('/latest.txt', (context) => serveLatest({}, context.env)) +app.get('/:chainId/latest.txt', (context) => + serveLatest({ chainId: context.req.param('chainId') }, context.env), +) +app.get('/:chainId/manifest.json', (context) => + serveManifest({ chainId: context.req.param('chainId') }, context.env), +) +app.get('/:chainId/:snapshotName', (context) => + serveSnapshot( + { + headers: context.req.raw.headers, + snapshotName: context.req.param('snapshotName'), + }, + context.env, + ), +) +app.get('/', (context) => handleUI(context.req.raw, context.env)) + +export default app + +async function serveLatest( + { chainId = DEFAULT_CHAIN_ID }: { chainId?: string }, + env: Env, +): Promise { + const snapshots = await getFullSnapshots(env) + const latest = snapshots.find( + (snapshot) => snapshot.chainId === chainId && !snapshot.isModular, + ) + + if (!latest) { + return error(404, 'No non-modular snapshots found') + } + + return text(latest.archiveFile) +} + +async function serveManifest( + { chainId }: { chainId: string }, + env: Env, +): Promise { + const snapshots = await getFullSnapshots(env) + const latest = snapshots.find( + (snapshot) => + snapshot.chainId === chainId && + snapshot.isModular && + snapshot.manifestKey, + ) + + if (!latest?.manifestKey) { + return error(404, 'Manifest not found') + } + + const key = latest.manifestKey + const obj = await env.SNAPSHOTS.get(key) + if (!obj) { + return error(404, 'Manifest not found') + } + const body = await obj.text() + return new Response(body, { + headers: { 'Content-Type': 'application/json' }, + }) +} + +async function serveSnapshot( + { headers, snapshotName }: { snapshotName: string; headers: Headers }, + env: Env, +): Promise { + const rangeHeader = headers.get('Range') + + const object = await env.SNAPSHOTS.get(snapshotName, { + onlyIf: headers, + range: headers, + }) + + if (object === null) { + return error(404, 'Object Not Found') + } + + const newHeaders = new Headers() + object.writeHttpMetadata(newHeaders) + newHeaders.set('etag', object.httpEtag) + newHeaders.set('Accept-Ranges', 'bytes') + + // When no body is present, preconditions have failed + if (!('body' in object)) { + return new Response(undefined, { status: 412, headers: newHeaders }) + } + + // Handle range requests - R2 returns the range in object.range when a valid Range header was provided + if (rangeHeader && object.range) { + const range = object.range as { offset: number; length: number } + const start = range.offset + const end = range.offset + range.length - 1 + const total = object.size + + newHeaders.set('Content-Range', `bytes ${start}-${end}/${total}`) + newHeaders.set('Content-Length', range.length.toString()) + + return new Response(object.body, { + status: 206, + headers: newHeaders, + }) + } + + // Full response + newHeaders.set('Content-Length', object.size.toString()) + return new Response(object.body, { + status: 200, + headers: newHeaders, + }) +} + +async function listAllObjects( + bucket: R2Bucket, + prefix?: string, +): Promise { + const objects: R2Object[] = [] + let cursor: string | undefined + while (true) { + const res = await withR2Retry( + `listing objects for prefix ${prefix || ''}`, + () => bucket.list({ cursor, prefix }), + ) + objects.push(...res.objects) + if (!res.truncated) break + cursor = res.cursor + } + return objects +} + +// List top-level directories and root objects using R2 delimiter (paginated) +async function listRoot( + bucket: R2Bucket, +): Promise<{ dirs: string[]; objects: R2Object[] }> { + const dirs: string[] = [] + const objects: R2Object[] = [] + let cursor: string | undefined + while (true) { + const res = await withR2Retry('listing root snapshot prefixes', () => + bucket.list({ cursor, delimiter: '/' }), + ) + if (res.delimitedPrefixes) { + dirs.push(...res.delimitedPrefixes) + } + objects.push(...res.objects) + if (!res.truncated) break + cursor = res.cursor + } + return { dirs, objects } +} + +// Check if all files referenced by a manifest exist in R2 +function isManifestComplete( + manifest: SnapshotManifest, + dirPrefix: string, + keySet: Set, +): boolean { + for (const [name, comp] of Object.entries(manifest.components)) { + if (isSingleArchive(comp)) { + if (!keySet.has(`${dirPrefix}/${comp.file}`)) return false + } else { + const numChunks = Math.ceil(comp.total_blocks / comp.blocks_per_file) + for (let i = 0; i < numChunks; i++) { + const start = i * comp.blocks_per_file + const end = (i + 1) * comp.blocks_per_file - 1 + const chunkKey = `${dirPrefix}/${name}-${start}-${end}.tar.zst` + if (!keySet.has(chunkKey)) return false + } + } + } + return true +} + +// Fetch and parse all snapshots from R2 +async function getSnapshots(env: Env): Promise { + // Step 1: List top-level directories and root files using delimiter (paginated) + // This is O(dirs) instead of O(all_objects) — much faster + const { dirs, objects: rootObjects } = await listRoot(env.SNAPSHOTS) + + // Step 2: Identify legacy metadata files at root level + const legacyMetadataFiles = rootObjects.filter((obj) => + obj.key.endsWith('.json'), + ) + + // Fetch manifests in parallel — only need to get manifest.json from each dir + const manifestPromises = dirs.map(async (dir): Promise => { + const dirName = dir.replace(/\/$/, '') + const manifestKey = `${dirName}/manifest.json` + + try { + const obj = await withR2Retry(`fetching manifest ${manifestKey}`, () => + env.SNAPSHOTS.get(manifestKey), + ) + if (!obj) return null + + const manifest: SnapshotManifest = await obj.json() + + // List objects in this directory to verify completeness + const dirObjects = await listAllObjects(env.SNAPSHOTS, dir) + const keySet = new Set(dirObjects.map((o) => o.key)) + + if (!isManifestComplete(manifest, dirName, keySet)) { + console.warn(`Skipping incomplete snapshot: ${manifestKey}`) + return null + } + + const chainId = String(manifest.chain_id) + const network = getNetworkInfo(chainId) + const baseUrl = `${env.R2_PUBLIC_URL}/${dirName}` + + const date = new Date(manifest.timestamp * 1000) + .toISOString() + .split('T')[0] + + const components: SnapshotComponent[] = [] + let totalSize = 0 + + for (const [name, comp] of Object.entries(manifest.components)) { + const displayName = COMPONENT_DISPLAY_NAMES[name] || name + const size = getComponentSize(comp) + components.push({ name, displayName, size }) + totalSize += size + } + + const manifestUrl = `${baseUrl}/manifest.json` + return { + snapshotId: manifestUrl, + chainId, + networkKey: network.key, + networkName: network.name, + block: manifest.block, + timestamp: String(manifest.timestamp), + date, + image: + manifest.tempo_version || + manifest.reth_version || + manifest.image || + 'unknown', + archiveUrl: manifestUrl, + archiveFile: manifestKey, + metadataUrl: `${env.R2_PUBLIC_URL}/${manifestKey}`, + size: totalSize, + isModular: true, + components, + manifestUrl, + manifestKey, + rawManifest: manifest, + } + } catch (err) { + console.error(`Failed to parse manifest ${manifestKey}:`, err) + return null + } + }) + + // Fetch legacy metadata in parallel + const legacyPromises = legacyMetadataFiles.map( + async (file): Promise => { + try { + const obj = await withR2Retry( + `fetching legacy metadata ${file.key}`, + () => env.SNAPSHOTS.get(file.key), + ) + if (!obj) return null + + const metadata: LegacyMetadata = await obj.json() + const network = getNetworkInfo(metadata.chain_id) + + const archiveUrl = `${env.R2_PUBLIC_URL}/${metadata.archive}` + const metadataUrl = `${env.R2_PUBLIC_URL}/${file.key}` + + const date = new Date(parseInt(metadata.timestamp, 10) * 1000) + .toISOString() + .split('T')[0] + + // Look up archive size — skip if archive is missing + const archiveHead = await withR2Retry( + `checking archive ${metadata.archive}`, + () => env.SNAPSHOTS.head(metadata.archive), + ) + if (!archiveHead) { + console.warn( + `Skipping legacy snapshot with missing archive: ${metadata.archive}`, + ) + return null + } + const archiveSize = archiveHead.size + + return { + snapshotId: metadataUrl, + chainId: metadata.chain_id, + networkKey: network.key, + networkName: network.name, + block: metadata.block, + timestamp: metadata.timestamp, + date, + image: metadata.image || 'legacy', + archiveUrl, + archiveFile: metadata.archive, + metadataUrl, + size: archiveSize, + isModular: false, + } + } catch (err) { + console.error(`Failed to parse ${file.key}:`, err) + return null + } + }, + ) + + const [manifestResults, legacyResults] = await Promise.all([ + Promise.all(manifestPromises), + Promise.all(legacyPromises), + ]) + + const snapshots = [ + ...manifestResults.filter((s): s is Snapshot => s !== null), + ...legacyResults.filter((s): s is Snapshot => s !== null), + ] + + snapshots.sort( + (a, b) => parseInt(b.timestamp, 10) - parseInt(a.timestamp, 10), + ) + + return snapshots +} + +const CACHE_KEY_FULL = 'https://snapshots.tempoxyz.dev/cache/full' +const CACHE_KEY_API = 'https://snapshots.tempoxyz.dev/cache/api' +const CACHE_TTL = 3600 // 1 hour — snapshots change at most once per day + +// Strip rawManifest (contains huge chunk_sizes arrays) from snapshots for API +function stripRawManifests(snapshots: Snapshot[]): Snapshot[] { + return snapshots.map(({ rawManifest, ...rest }) => rest) +} + +// Populate both caches from a fresh snapshot list +async function populateSnapshotCaches( + cache: Cache, + snapshots: Snapshot[], +): Promise { + const full = new Response(JSON.stringify(snapshots), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': `public, max-age=${CACHE_TTL}`, + }, + }) + const stripped = new Response(JSON.stringify(stripRawManifests(snapshots)), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': `public, max-age=${CACHE_TTL}`, + }, + }) + await Promise.all([ + cache.put(CACHE_KEY_FULL, full), + cache.put(CACHE_KEY_API, stripped), + ]) +} + +// Canonical source: always returns full snapshots with rawManifest +async function getFullSnapshots(env: Env): Promise { + const cache = caches.default + const cached = await cache.match(CACHE_KEY_FULL) + if (cached) { + return cached.json() + } + + const snapshots = await getSnapshots(env) + await populateSnapshotCaches(cache, snapshots) + return snapshots +} + +// For UI rendering — alias for getFullSnapshots +async function getCachedSnapshotsForUI(env: Env): Promise { + return getFullSnapshots(env) +} + +// Handle API requests — returns stripped snapshots (no rawManifest/chunk_sizes) +async function handleAPI(_req: Request, env: Env): Promise { + const cache = caches.default + const cached = await cache.match(CACHE_KEY_API) + if (cached) { + return new Response(cached.body, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': `public, max-age=${CACHE_TTL}`, + }, + }) + } + + // Populates both caches, return stripped + const snapshots = await getFullSnapshots(env) + const stripped = stripRawManifests(snapshots) + return new Response(JSON.stringify(stripped), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': `public, max-age=${CACHE_TTL}`, + }, + }) +} + +// Serve HTML UI with server-side rendered data +async function handleUI(_req: Request, env: Env) { + // Serve cached HTML from CF edge cache to avoid re-rendering + const cache = caches.default + const cacheKey = new Request('https://snapshots.tempoxyz.dev/ui-html', { + method: 'GET', + }) + const cachedHtml = await cache.match(cacheKey) + if (cachedHtml) { + return cachedHtml + } + + const snapshots = await getCachedSnapshotsForUI(env) + const defaultPresetSizes: PresetSizes = { + minimal: { + state: 0, + headers: 0, + transactions: 0, + transaction_senders: 0, + receipts: 0, + account_changesets: 0, + storage_changesets: 0, + rocksdb_indices: 0, + }, + full: { + state: 0, + headers: 0, + transactions: 0, + transaction_senders: 0, + receipts: 0, + account_changesets: 0, + storage_changesets: 0, + rocksdb_indices: 0, + }, + archive: { + state: 0, + headers: 0, + transactions: 0, + transaction_senders: 0, + receipts: 0, + account_changesets: 0, + storage_changesets: 0, + rocksdb_indices: 0, + }, + } + const chainIds = [ + ...new Set(snapshots.map((snapshot) => snapshot.chainId)), + ].sort(compareChainIds) + const snapshotsByChain = Object.fromEntries( + chainIds.map((chainId) => [ + chainId, + snapshots.filter((snapshot) => snapshot.chainId === chainId), + ]), + ) as Record + const modularSnapshots = snapshots.filter((s) => s.isModular && s.rawManifest) + const modularSnapshotsByChain = Object.fromEntries( + chainIds.map((chainId) => [ + chainId, + modularSnapshots.filter((snapshot) => snapshot.chainId === chainId), + ]), + ) as Record + const snapshotPresetSizes: Record = {} + for (const s of modularSnapshots) { + snapshotPresetSizes[s.snapshotId] = s.rawManifest + ? getPresetSizesFromManifest(s.rawManifest) + : defaultPresetSizes + } + const selectedChainId = chainIds.includes(DEFAULT_CHAIN_ID) + ? DEFAULT_CHAIN_ID + : chainIds[0] || DEFAULT_CHAIN_ID + const selectedNetworkSnapshots = + modularSnapshotsByChain[selectedChainId] || [] + const latestModular = selectedNetworkSnapshots[0] + const presetSizes = latestModular?.rawManifest + ? getPresetSizesFromManifest(latestModular.rawManifest) + : defaultPresetSizes + const networkOptions = chainIds.map((chainId) => { + const network = getNetworkInfo(chainId) + const latestSnapshot = snapshotsByChain[chainId]?.[0] + + return { + chainId, + key: network.key, + name: network.name, + latestBlock: latestSnapshot?.block ?? null, + latestTimestamp: latestSnapshot?.timestamp ?? null, + hasModular: (modularSnapshotsByChain[chainId] || []).length > 0, + } + }) + const modularSnapshotOptionsByChain = Object.fromEntries( + chainIds.map((chainId) => [ + chainId, + (modularSnapshotsByChain[chainId] || []).map((snapshot) => ({ + snapshotId: snapshot.snapshotId, + block: snapshot.block, + timestamp: snapshot.timestamp, + date: snapshot.date, + image: snapshot.image, + manifestUrl: snapshot.manifestUrl, + networkName: snapshot.networkName, + })), + ]), + ) + const latestSnapshotsByChain = Object.fromEntries( + chainIds.map((chainId) => { + const latestSnapshot = snapshotsByChain[chainId]?.[0] + + return [ + chainId, + latestSnapshot + ? { + snapshotId: latestSnapshot.snapshotId, + block: latestSnapshot.block, + timestamp: latestSnapshot.timestamp, + date: latestSnapshot.date, + image: latestSnapshot.image, + archiveUrl: latestSnapshot.archiveUrl, + manifestUrl: latestSnapshot.manifestUrl || null, + isModular: latestSnapshot.isModular, + networkName: latestSnapshot.networkName, + } + : null, + ] + }), + ) + const body = ` + + + + + Tempo Snapshots + + + + + + + + + + + + + + + + + + +
+
+ +

Tempo Snapshots

+

Configure your own node with our modular snapshots.

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ $ + tempo download + +
+ + +
+ +
+ + + +
+ +
+
+ Estimated download size + ~200 GB +
+
+
+
Download sizes are compressed. On-disk usage will be larger after extraction.
+
+ +
+
Unlocked capabilities
+
+
+ +
+
+ + Component + Description + Size +
+
+
+ +
+
+ +
+ Latest snapshot: block ${latestModular ? latestModular.block.toLocaleString() : '—'} · ${latestModular ? new Date(parseInt(latestModular.timestamp, 10) * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'} + · + Node docs + · + GitHub +
+ + + +` + + const response = new Response(body, { + headers: { + 'Content-Type': 'text/html;charset=utf-8', + 'Cache-Control': `public, max-age=${CACHE_TTL}`, + }, + }) + + // Cache the rendered HTML at the edge + await cache.put(cacheKey, response.clone()) + + return response +} diff --git a/apps/tempo-snapshots-viewer/tsconfig.json b/apps/tempo-snapshots-viewer/tsconfig.json new file mode 100644 index 000000000..9cd7b5859 --- /dev/null +++ b/apps/tempo-snapshots-viewer/tsconfig.json @@ -0,0 +1,20 @@ +{ + "schema": "https://json.schemastore.org/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types", "node"], + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["worker-configuration.d.ts"], + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/apps/tempo-snapshots-viewer/wrangler.json b/apps/tempo-snapshots-viewer/wrangler.json new file mode 100644 index 000000000..4eddbc29a --- /dev/null +++ b/apps/tempo-snapshots-viewer/wrangler.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://esm.sh/wrangler/config-schema.json", + "name": "tempo-snapshots-viewer", + "main": "./src/index.ts", + "compatibility_date": "2025-12-17", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": false, + "preview_urls": true, + "keep_vars": true, + "observability": { + "enabled": true, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "invocation_logs": true, + "persist": true + } + }, + "r2_buckets": [ + { + "binding": "SNAPSHOTS", + "bucket_name": "tempo-node-snapshots", + "remote": true + } + ], + "vars": { + "R2_PUBLIC_URL": "https://tempo-node-snapshots.tempoxyz.dev" + }, + "routes": [ + { + "pattern": "snapshots.tempoxyz.dev", + "custom_domain": true + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4d2aefa9..8c76bfd13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -807,6 +807,28 @@ importers: specifier: 'catalog:' version: 4.79.0(@cloudflare/workers-types@4.20260408.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + apps/tempo-snapshots-viewer: + dependencies: + hono: + specifier: 'catalog:' + version: 4.12.14 + devDependencies: + '@biomejs/biome': + specifier: 'catalog:' + version: 2.4.10 + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20260408.1 + '@types/node': + specifier: 'catalog:' + version: 25.5.2 + typescript: + specifier: 'catalog:' + version: 6.0.2 + wrangler: + specifier: 'catalog:' + version: 4.79.0(@cloudflare/workers-types@4.20260408.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + apps/tokenlist: dependencies: hono: From a9846fcd22aade600f1c32c49b9d0d94276bd3ba Mon Sep 17 00:00:00 2001 From: Emma Jamieson-Hoare Date: Wed, 6 May 2026 10:47:48 +0100 Subject: [PATCH 2/3] fix: enable snapshots viewer previews --- apps/tempo-snapshots-viewer/wrangler.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tempo-snapshots-viewer/wrangler.json b/apps/tempo-snapshots-viewer/wrangler.json index 4eddbc29a..a634200bc 100644 --- a/apps/tempo-snapshots-viewer/wrangler.json +++ b/apps/tempo-snapshots-viewer/wrangler.json @@ -4,7 +4,7 @@ "main": "./src/index.ts", "compatibility_date": "2025-12-17", "compatibility_flags": ["nodejs_compat"], - "workers_dev": false, + "workers_dev": true, "preview_urls": true, "keep_vars": true, "observability": { From 8573aa214d6b36df4eacb46d19e2d389d0cf145e Mon Sep 17 00:00:00 2001 From: Emma Jamieson-Hoare Date: Wed, 6 May 2026 10:53:26 +0100 Subject: [PATCH 3/3] fix: apply snapshots preview triggers --- .github/workflows/pull-request.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b468de11e..3042433f4 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -112,6 +112,16 @@ jobs: run: pnpm build working-directory: apps/${{ matrix.app }} + - name: Apply Preview Triggers + if: steps.changes.outputs.relevant == 'true' && matrix.app == 'tempo-snapshots-viewer' + uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: apps/${{ matrix.app }} + environment: ${{ env.CLOUDFLARE_ENV }} + command: triggers deploy + - name: Upload Worker Version if: steps.changes.outputs.relevant == 'true' id: deploy