diff --git a/app/api/internal-explorer/block-list.ts b/app/api/internal-explorer/block-list.ts index 36ec49d..7410277 100644 --- a/app/api/internal-explorer/block-list.ts +++ b/app/api/internal-explorer/block-list.ts @@ -120,6 +120,21 @@ async function fetchBlockByNumber( }; } +export async function fetchBlocksByNumbers( + rpcUrl: string, + numbers: number[], +): Promise { + const blocks = await Promise.all( + numbers.map((blockNumber) => fetchBlockByNumber(rpcUrl, blockNumber)), + ); + + if (blocks.some((block) => block === null)) { + throw new BlockListUnavailableError('failed to fetch one or more blocks'); + } + + return blocks as BlockSummary[]; +} + export async function listBlocks(rpcUrl: string, query: BlockListQuery): Promise { const latestBlockNumber = await fetchLatestBlockNumber(rpcUrl); if (latestBlockNumber === null) { diff --git a/app/api/internal-explorer/blocks-by-numbers/route.ts b/app/api/internal-explorer/blocks-by-numbers/route.ts new file mode 100644 index 0000000..25309a8 --- /dev/null +++ b/app/api/internal-explorer/blocks-by-numbers/route.ts @@ -0,0 +1,55 @@ +import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { BlockListUnavailableError, fetchBlocksByNumbers } from '../block-list'; +import { getRpcUrl } from '../config'; +import { explorerDisabledResponse } from '../guard'; + +export const runtime = 'nodejs'; + +export type { BlockSummary } from '../block-list'; + +const MAX_NUMBERS = 100; + +export async function GET(request: Request) { + const disabled = explorerDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const chain = resolveExplorerChain(url.searchParams.get('chain')); + + const raw = url.searchParams.get('numbers'); + if (!raw) { + return Response.json({ error: 'Missing block numbers' }, { status: 400 }); + } + + const numbers = raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + + if (numbers.length === 0 || numbers.length > MAX_NUMBERS) { + return Response.json({ error: 'Invalid block numbers' }, { status: 400 }); + } + + if (numbers.some((value) => !/^(0|[1-9]\d*)$/.test(value))) { + return Response.json({ error: 'Invalid block numbers' }, { status: 400 }); + } + + const parsed = numbers.map(Number); + if (parsed.some((value) => !Number.isSafeInteger(value))) { + return Response.json({ error: 'Invalid block numbers' }, { status: 400 }); + } + + try { + return Response.json(await fetchBlocksByNumbers(getRpcUrl(chain), parsed)); + } catch (error) { + return Response.json( + { + error: + error instanceof BlockListUnavailableError + ? 'Block list unavailable' + : 'Internal server error', + }, + { status: error instanceof BlockListUnavailableError ? 503 : 500 }, + ); + } +} diff --git a/app/api/internal-explorer/shadow-blocks/route.ts b/app/api/internal-explorer/shadow-blocks/route.ts new file mode 100644 index 0000000..0ef5d32 --- /dev/null +++ b/app/api/internal-explorer/shadow-blocks/route.ts @@ -0,0 +1,48 @@ +import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { getShadowMetricsUrl } from '../config'; +import { explorerDisabledResponse } from '../guard'; +import { ShadowUnavailableError, fetchRecentShadowBlocks } from '../shadow'; + +export const runtime = 'nodejs'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +// undefined = absent; null = present but malformed (caller should reject). +function parsePositiveInt(value: string | null): number | undefined | null { + if (value === null) return undefined; + if (!/^[1-9]\d*$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +export async function GET(request: Request) { + const disabled = explorerDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const chain = resolveExplorerChain(url.searchParams.get('chain')); + + const baseUrl = getShadowMetricsUrl(chain); + if (!baseUrl) { + return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); + } + + const rawLimit = parsePositiveInt(url.searchParams.get('limit')); + const before = parsePositiveInt(url.searchParams.get('before')); + if (rawLimit === null || before === null) { + return Response.json({ error: 'Invalid limit or before' }, { status: 400 }); + } + + const limit = Math.min(rawLimit ?? DEFAULT_LIMIT, MAX_LIMIT); + + try { + return Response.json(await fetchRecentShadowBlocks(baseUrl, { limit, before })); + } catch (error) { + console.error('Error fetching recent shadow blocks:', error); + return Response.json( + { error: 'Shadow blocks unavailable' }, + { status: error instanceof ShadowUnavailableError ? 503 : 500 }, + ); + } +} diff --git a/app/api/internal-explorer/shadow.ts b/app/api/internal-explorer/shadow.ts index a6dc4cb..756411a 100644 --- a/app/api/internal-explorer/shadow.ts +++ b/app/api/internal-explorer/shadow.ts @@ -147,6 +147,20 @@ export async function fetchShadowCandidatesBatch( } } +export async function fetchRecentShadowBlocks( + baseUrl: string, + options: { limit?: number; before?: number } = {}, +): Promise { + const root = baseUrl.replace(/\/$/, ''); + const params = new URLSearchParams(); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + if (options.before !== undefined) params.set('before', String(options.before)); + const query = params.toString(); + const url = query ? `${root}/shadow-blocks?${query}` : `${root}/shadow-blocks`; + const summaries = await fetchShadowMetrics(url); + return summaries.map(normalizeShadowSummary); +} + export async function fetchShadowBlockSummary( baseUrl: string, hash: string, diff --git a/app/internal-explorer/blocks/page.tsx b/app/internal-explorer/blocks/page.tsx index cecee63..d90d7c3 100644 --- a/app/internal-explorer/blocks/page.tsx +++ b/app/internal-explorer/blocks/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useMemo, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import { Card } from '../../components/ui/Card'; import { Spinner } from '../../components/ui/Spinner'; @@ -15,11 +15,13 @@ import { formatInteger } from '../library/explorer-format'; import { explorerHref } from '../library/links'; import type { BlocksResponse, ShadowBlockSummary } from '../library/types'; import { useExplorerChain } from '../library/useExplorerChain'; +import { useShadowDelta } from '../library/useShadowDelta'; const PAGE_LIMIT = 25; function BlocksContent() { const { chain } = useExplorerChain(); + const { showShadowDelta, setShowShadowDelta } = useShadowDelta(); const searchParams = useSearchParams(); const cursorParam = searchParams.get('cursor'); const cursor = cursorParam !== null ? Number(cursorParam) : undefined; @@ -27,19 +29,19 @@ function BlocksContent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [showShadowDelta, setShowShadowDelta] = useState(false); const [shadowCandidates, setShadowCandidates] = useState>({}); - const shadowKey = useMemo( - () => (data?.blocks ?? []).map((block) => block.hash.toLowerCase()).sort().join(','), - [data?.blocks], - ); + // Canonical-block view: paginate the chain tip directly. The shadow-delta + // view has its own source below, so this fetch stands down when it is on. useEffect(() => { + if (showShadowDelta) return undefined; + let cancelled = false; const controller = new AbortController(); setLoading(true); setError(null); setData(null); + setShadowCandidates({}); explorerApi .blocksPage(chain, { cursor, limit: PAGE_LIMIT }, controller.signal) @@ -58,26 +60,72 @@ function BlocksContent() { cancelled = true; controller.abort(); }; - }, [chain, cursor]); + }, [chain, cursor, showShadowDelta]); + // The canonical tip rarely holds a block with a shadow replacement, so this + // view is driven by recent shadow blocks (dense, newest first) with each + // canonical block resolved by number to fill the base columns. useEffect(() => { - if (!showShadowDelta || shadowKey.length === 0) { - setShadowCandidates({}); - return undefined; - } + if (!showShadowDelta) return undefined; + let cancelled = false; const controller = new AbortController(); - const hashes = shadowKey.split(','); + setLoading(true); + setError(null); + setData(null); + setShadowCandidates({}); explorerApi - .shadowCandidatesBatch(chain, hashes, controller.signal) - .then((response) => setShadowCandidates(response)) - .catch(() => setShadowCandidates({})); + .recentShadowBlocks(chain, { limit: PAGE_LIMIT }, controller.signal) + .then(async (shadows) => { + if (cancelled) return; + if (shadows.length === 0) { + setData({ + blocks: [], + page: { cursor: null, limit: PAGE_LIMIT, latestBlockNumber: 0, nextCursor: null, hasMore: false }, + }); + return; + } + + const numbers = shadows.map((shadow) => shadow.number); + const canonicalBlocks = await explorerApi.blocksByNumbers(chain, numbers, controller.signal); + if (cancelled) return; + + const canonicalByNumber = new Map(canonicalBlocks.map((block) => [block.number, block])); + const orderedBlocks = shadows + .map((shadow) => canonicalByNumber.get(shadow.number)) + .filter((block): block is NonNullable => block !== undefined); + const candidates = Object.fromEntries( + shadows + .filter((shadow) => shadow.canonicalHash !== undefined) + .map((shadow) => [shadow.canonicalHash!.toLowerCase(), [shadow]]), + ); + + setShadowCandidates(candidates); + setData({ + blocks: orderedBlocks, + page: { + cursor: null, + limit: PAGE_LIMIT, + latestBlockNumber: orderedBlocks[0]?.number ?? 0, + nextCursor: null, + hasMore: false, + }, + }); + }) + .catch(() => { + if (controller.signal.aborted || cancelled) return; + setError('Failed to fetch shadow blocks'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); return () => { + cancelled = true; controller.abort(); }; - }, [chain, shadowKey, showShadowDelta]); + }, [chain, showShadowDelta]); return (
diff --git a/app/internal-explorer/library/client.ts b/app/internal-explorer/library/client.ts index 4c888cd..9533372 100644 --- a/app/internal-explorer/library/client.ts +++ b/app/internal-explorer/library/client.ts @@ -8,6 +8,7 @@ import type { ExplorerChain } from '../chains'; import type { BlockDetailResponse, BlocksResponse, + BlockSummary, LatestActiveBlockResponse, BundleHistoryResponse, RejectedTransactionsResponse, @@ -119,4 +120,23 @@ export const explorerApi = { chain, signal, ), + recentShadowBlocks: ( + chain: ExplorerChain, + options?: { limit?: number; before?: number }, + signal?: AbortSignal, + ) => + get( + withQuery('/api/internal-explorer/shadow-blocks', { + limit: options?.limit, + before: options?.before, + }), + chain, + signal, + ), + blocksByNumbers: (chain: ExplorerChain, numbers: number[], signal?: AbortSignal) => + get( + withQuery('/api/internal-explorer/blocks-by-numbers', { numbers: numbers.join(',') }), + chain, + signal, + ), }; diff --git a/app/internal-explorer/library/useShadowDelta.ts b/app/internal-explorer/library/useShadowDelta.ts new file mode 100644 index 0000000..aa3b060 --- /dev/null +++ b/app/internal-explorer/library/useShadowDelta.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useCallback } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +type UseShadowDelta = { + /** Whether the shadow-delta view is enabled, read from `?shadowDelta=`. */ + showShadowDelta: boolean; + /** Update `?shadowDelta=` in place, preserving the path and other query params. */ + setShowShadowDelta: (next: boolean) => void; +}; + +// Reads the shadow-delta toggle from the URL (`?shadowDelta=1`) and provides a +// setter that rewrites just that query param via router.replace — so the toggle +// persists across refresh and navigation, stays shareable, and never pushes a +// new history entry. Mirrors useExplorerChain. +export function useShadowDelta(): UseShadowDelta { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const showShadowDelta = searchParams.get('shadowDelta') === '1'; + + const setShowShadowDelta = useCallback( + (next: boolean) => { + const params = new URLSearchParams(searchParams.toString()); + if (next) { + params.set('shadowDelta', '1'); + } else { + params.delete('shadowDelta'); + } + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [router, pathname, searchParams], + ); + + return { showShadowDelta, setShowShadowDelta }; +} diff --git a/app/internal-explorer/page.tsx b/app/internal-explorer/page.tsx index 10832d8..dc1bfef 100644 --- a/app/internal-explorer/page.tsx +++ b/app/internal-explorer/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { Suspense, useCallback, useEffect, useState } from 'react'; import type { FormEvent } from 'react'; import { Banner } from '../components/ui/Banner'; @@ -33,6 +33,7 @@ import { type ShadowBlockSummary, } from './library/types'; import { useExplorerChain } from './library/useExplorerChain'; +import { useShadowDelta } from './library/useShadowDelta'; type Tab = 'blocks' | 'rejected'; @@ -203,6 +204,8 @@ function BlockRow({ ); } +const HOME_BLOCK_LIMIT = 10; + function BlocksTab({ chain, onError, @@ -212,20 +215,23 @@ function BlocksTab({ }) { const [blocks, setBlocks] = useState([]); const [loading, setLoading] = useState(true); - const [showShadowDelta, setShowShadowDelta] = useState(false); + const { showShadowDelta, setShowShadowDelta } = useShadowDelta(); const [shadowCandidates, setShadowCandidates] = useState>({}); - const shadowKey = useMemo( - () => blocks.map((block) => block.hash.toLowerCase()).sort().join(','), - [blocks], - ); + // Canonical-block view: poll the chain tip directly. The shadow-delta view + // has its own source below, so this fetch stands down when it is on. useEffect(() => { + if (showShadowDelta) return undefined; + let cancelled = false; + const controller = new AbortController(); setLoading(true); + setBlocks([]); + setShadowCandidates({}); async function load() { try { - const data = await explorerApi.blocks(chain); + const data = await explorerApi.blocks(chain, controller.signal); if (!cancelled) setBlocks(data.blocks); } catch { // Transient errors are expected between polls; keep the last good data. @@ -238,28 +244,76 @@ function BlocksTab({ const timer = window.setInterval(() => void load(), 2000); return () => { cancelled = true; + controller.abort(); window.clearInterval(timer); }; - }, [chain]); + }, [chain, showShadowDelta]); + // The canonical tip rarely holds a block with a shadow replacement, so this + // view is driven by recent shadow blocks (dense, newest first) with each + // canonical block resolved by number to fill the base columns. useEffect(() => { - if (!showShadowDelta || shadowKey.length === 0) { - setShadowCandidates({}); - return undefined; - } + if (!showShadowDelta) return undefined; - const controller = new AbortController(); - const hashes = shadowKey.split(','); + let cancelled = false; + let inFlight = false; + let activeController: AbortController | null = null; + setLoading(true); + setBlocks([]); + setShadowCandidates({}); - explorerApi - .shadowCandidatesBatch(chain, hashes, controller.signal) - .then((response) => setShadowCandidates(response)) - .catch(() => setShadowCandidates({})); + async function load() { + // This poll chains two requests, so skip if the previous one is still + // running to avoid stacking calls and clobbering fresh data out of order. + if (inFlight) return; + inFlight = true; + const controller = new AbortController(); + activeController = controller; + try { + const shadows = await explorerApi.recentShadowBlocks( + chain, + { limit: HOME_BLOCK_LIMIT }, + controller.signal, + ); + if (cancelled) return; + if (shadows.length === 0) { + setBlocks([]); + setShadowCandidates({}); + return; + } + + const numbers = shadows.map((shadow) => shadow.number); + const canonicalBlocks = await explorerApi.blocksByNumbers(chain, numbers, controller.signal); + if (cancelled) return; + + const canonicalByNumber = new Map(canonicalBlocks.map((block) => [block.number, block])); + const orderedBlocks = shadows + .map((shadow) => canonicalByNumber.get(shadow.number)) + .filter((block): block is BlockSummary => block !== undefined); + const candidates = Object.fromEntries( + shadows + .filter((shadow) => shadow.canonicalHash !== undefined) + .map((shadow) => [shadow.canonicalHash!.toLowerCase(), [shadow]]), + ); + + setBlocks(orderedBlocks); + setShadowCandidates(candidates); + } catch { + // Keep last good data across transient poll failures. + } finally { + inFlight = false; + if (!cancelled) setLoading(false); + } + } + void load(); + const timer = window.setInterval(() => void load(), 2000); return () => { - controller.abort(); + cancelled = true; + activeController?.abort(); + window.clearInterval(timer); }; - }, [chain, shadowKey, showShadowDelta]); + }, [chain, showShadowDelta]); return (