From 5a3f641e1ab66b51d65862d9750c5e49cf15dd2c Mon Sep 17 00:00:00 2001 From: Mateus Ribeiro Date: Fri, 21 Aug 2026 18:32:13 -0300 Subject: [PATCH 1/4] feat : shareable game links Adds a Share button to the analysis toolbar and three URL formats the analysis page can load a game from. `?pgn=` carries the game in the link itself. Chesskit is a static export with no backend, so this is the only format that cannot fail to resolve, and it works the same whatever the game's origin. Compression uses the browser's CompressionStream, so no dependency is added; a real 3120 char Chess.com PGN encodes to 1491 chars. A raw base64url fallback covers browsers without CompressionStream, and a one character format tag lets the encoding change later without breaking links already shared. `?chessComUsername=&chessComGameId=` resolves through the public archives API. Chess.com exposes no single game endpoint, and the undocumented `/callback/live/game/{id}` route sends no CORS headers so it is unusable from the browser. Archives are the only option: the scan walks newest first and is bounded to six months, because a single archive is ~700KB and an active player can have over 150 of them. `?lichessGameId=` already existed; it now shares the same loading and error path as the other two. The share link is built ahead of the click rather than inside the handler. Compressing the PGN is async, and Safari drops the user activation across an await, so writing to the clipboard afterwards fails with NotAllowedError. Preparing the link up front keeps the click handler synchronous, matching how CopyPgnButton already works. Malformed links land on an empty board rather than crashing, decoded PGNs are capped at 500KB so a hand crafted link cannot inflate a decompression bomb, and in-flight fetches abort when the params change. --- src/lib/chessCom.ts | 55 +++++++ src/lib/shareLink.ts | 118 ++++++++++++++ .../analysis/panelHeader/loadGame.tsx | 151 ++++++++++++------ src/sections/analysis/panelToolbar/index.tsx | 3 + .../analysis/panelToolbar/shareButton.tsx | 83 ++++++++++ 5 files changed, 365 insertions(+), 45 deletions(-) create mode 100644 src/lib/shareLink.ts create mode 100644 src/sections/analysis/panelToolbar/shareButton.tsx diff --git a/src/lib/chessCom.ts b/src/lib/chessCom.ts index 73edcee7..ed394aa2 100644 --- a/src/lib/chessCom.ts +++ b/src/lib/chessCom.ts @@ -50,6 +50,61 @@ export const getChessComUserRecentGames = async ( return gamesToReturn; }; +/** + * Chess.com's public API exposes no single-game endpoint, and the undocumented + * `/callback/live/game/{id}` route sends no CORS headers, so it is unreachable + * from the browser. The only way to resolve a game id is to scan that player's + * monthly archives. + * + * Archives run ~700KB each and an active player can have well over a hundred of + * them, so the scan is bounded to the most recent months and walks newest first + * — shared games are nearly always recent, so this usually resolves on the first + * request. + */ +export const CHESS_COM_ARCHIVE_SCAN_LIMIT = 6; + +export const fetchChessComGame = async ( + username: string, + gameId: string, + signal?: AbortSignal +): Promise => { + const usernameParam = encodeURIComponent(username.trim().toLowerCase()); + + const archivesRes = await fetch( + `https://api.chess.com/pub/player/${usernameParam}/games/archives`, + { method: "GET", signal } + ); + + if (archivesRes.status >= 400) { + throw new Error(`Chess.com user "${username}" not found`); + } + + const archivesData = await archivesRes.json(); + const archives: string[] = archivesData?.archives ?? []; + + const archivesToScan = archives + .slice(-CHESS_COM_ARCHIVE_SCAN_LIMIT) + .reverse(); + + for (const archiveUrl of archivesToScan) { + const res = await fetch(archiveUrl, { method: "GET", signal }); + if (res.status >= 400) continue; + + const data = await res.json(); + const games: ChessComGame[] = data?.games ?? []; + + const game = games.find( + (game) => game.uuid === gameId || game.url?.split("/").pop() === gameId + ); + + if (game?.pgn) return game.pgn; + } + + throw new Error( + `Game ${gameId} not found in ${username}'s last ${CHESS_COM_ARCHIVE_SCAN_LIMIT} months on Chess.com` + ); +}; + export const getChessComUserAvatar = async ( username: string ): Promise => { diff --git a/src/lib/shareLink.ts b/src/lib/shareLink.ts new file mode 100644 index 00000000..685086b2 --- /dev/null +++ b/src/lib/shareLink.ts @@ -0,0 +1,118 @@ +/** + * Shareable game links. + * + * Chesskit is a static export with no backend, so a share link has to carry the + * game itself. PGNs are gzipped with the browser's CompressionStream and encoded + * as base64url, which keeps a typical Chess.com game (~3KB of PGN with clock + * annotations) down to roughly 1KB of URL. + * + * The payload is prefixed with a one character format tag so the encoding can + * change later without breaking links already in the wild. + */ + +const FORMAT_GZIP = "1"; +const FORMAT_RAW = "0"; + +/** Guards against a hand-crafted URL trying to inflate a decompression bomb. */ +const MAX_DECODED_PGN_LENGTH = 500_000; + +const isCompressionSupported = (): boolean => + typeof CompressionStream === "function" && + typeof DecompressionStream === "function"; + +const bytesToBase64Url = (bytes: Uint8Array): string => { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +}; + +const base64UrlToBytes = (value: string): Uint8Array => { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + "=" + ); + + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +}; + +const gzip = async (value: string): Promise => { + const stream = new Blob([value]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + + return new Uint8Array(await new Response(stream).arrayBuffer()); +}; + +const gunzip = async (bytes: Uint8Array): Promise => { + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + + return new Response(stream).text(); +}; + +/** Encodes a PGN into the value used by the `pgn` query param. */ +export const encodePgnParam = async (pgn: string): Promise => { + if (!isCompressionSupported()) { + return FORMAT_RAW + bytesToBase64Url(new TextEncoder().encode(pgn)); + } + + return FORMAT_GZIP + bytesToBase64Url(await gzip(pgn)); +}; + +/** + * Decodes a `pgn` query param back into a PGN string. + * Returns undefined for anything malformed — a bad link should land the user on + * an empty board, not a crash. + */ +export const decodePgnParam = async ( + param: string +): Promise => { + try { + const format = param.slice(0, 1); + const payload = param.slice(1); + if (!payload) return undefined; + + const bytes = base64UrlToBytes(payload); + + if (format === FORMAT_RAW) { + const pgn = new TextDecoder().decode(bytes); + return pgn.length > MAX_DECODED_PGN_LENGTH ? undefined : pgn; + } + + if (format === FORMAT_GZIP) { + if (!isCompressionSupported()) return undefined; + const pgn = await gunzip(bytes); + return pgn.length > MAX_DECODED_PGN_LENGTH ? undefined : pgn; + } + + return undefined; + } catch (error) { + console.error("Unable to decode shared PGN", error); + return undefined; + } +}; + +/** Builds a full share URL for a PGN, pointing at the analysis page. */ +export const buildPgnShareUrl = async ( + pgn: string, + orientation?: "white" | "black" +): Promise => { + const params = new URLSearchParams({ pgn: await encodePgnParam(pgn) }); + if (orientation === "black") params.set("orientation", "black"); + + return `${window.location.origin}/?${params.toString()}`; +}; diff --git a/src/sections/analysis/panelHeader/loadGame.tsx b/src/sections/analysis/panelHeader/loadGame.tsx index 30cd657c..355193bf 100644 --- a/src/sections/analysis/panelHeader/loadGame.tsx +++ b/src/sections/analysis/panelHeader/loadGame.tsx @@ -1,5 +1,5 @@ import LoadGameButton from "../../loadGame/loadGameButton"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useChessActions } from "@/hooks/useChessActions"; import { boardAtom, @@ -14,6 +14,9 @@ import { Chess } from "chess.js"; import { useRouter } from "next/router"; import { GameEval } from "@/types/eval"; import { fetchLichessGame } from "@/lib/lichess"; +import { fetchChessComGame } from "@/lib/chessCom"; +import { decodePgnParam } from "@/lib/shareLink"; +import { Alert, Snackbar } from "@mui/material"; export default function LoadGame() { const router = useRouter(); @@ -24,6 +27,8 @@ export default function LoadGame() { const setEval = useSetAtom(gameEvalAtom); const setBoardOrientation = useSetAtom(boardOrientationAtom); const evaluationProgress = useAtomValue(evaluationProgressAtom); + const [loadError, setLoadError] = useState(""); + const [isLoadingSharedGame, setIsLoadingSharedGame] = useState(false); const joinedGameHistory = useMemo(() => game.history().join(), [game]); @@ -41,13 +46,60 @@ export default function LoadGame() { [joinedGameHistory, resetBoard, setGamePgn, setEval, setBoardOrientation] ); - const { lichessGameId, orientation: orientationParam } = router.query; + const { + lichessGameId, + chessComUsername, + chessComGameId, + pgn: pgnParam, + orientation: orientationParam, + } = router.query; useEffect(() => { - const handleLichess = async (id: string) => { - const res = await fetchLichessGame(id); - if (typeof res === "string") { - resetAndSetGamePgn(res, orientationParam !== "black"); + const controller = new AbortController(); + const isWhiteOrientation = orientationParam !== "black"; + + const loadSharedGame = async () => { + setIsLoadingSharedGame(true); + setLoadError(""); + + try { + if (typeof pgnParam === "string" && pgnParam) { + const pgn = await decodePgnParam(pgnParam); + if (!pgn) throw new Error("This shared link is invalid or corrupted"); + resetAndSetGamePgn(pgn, isWhiteOrientation); + return; + } + + if (typeof lichessGameId === "string" && lichessGameId) { + const res = await fetchLichessGame(lichessGameId, controller.signal); + if (typeof res !== "string") { + throw new Error(`Unable to load Lichess game ${lichessGameId}`); + } + resetAndSetGamePgn(res, isWhiteOrientation); + return; + } + + if ( + typeof chessComUsername === "string" && + chessComUsername && + typeof chessComGameId === "string" && + chessComGameId + ) { + const pgn = await fetchChessComGame( + chessComUsername, + chessComGameId, + controller.signal + ); + resetAndSetGamePgn(pgn, isWhiteOrientation); + } + } catch (error) { + if (controller.signal.aborted) return; + console.error(error); + setLoadError( + error instanceof Error ? error.message : "Unable to load shared game" + ); + } finally { + if (!controller.signal.aborted) setIsLoadingSharedGame(false); } }; @@ -56,53 +108,62 @@ export default function LoadGame() { gameFromUrl.site === "Chesskit.org" && gameFromUrl.black.name === "You" ); resetAndSetGamePgn(gameFromUrl.pgn, orientation, gameFromUrl.eval); - } else if (typeof lichessGameId === "string" && !!lichessGameId) { - handleLichess(lichessGameId); + } else { + loadSharedGame(); } - }, [gameFromUrl, lichessGameId, orientationParam, resetAndSetGamePgn]); - - useEffect(() => { - const eventHandler = (event: MessageEvent) => { - try { - if (!event?.data?.pgn) return; - const { pgn, orientation } = event.data as { - pgn: string; - orientation?: "white" | "black"; - }; - resetAndSetGamePgn(pgn, orientation !== "black"); - } catch (error) { - console.error("Error processing message event:", error); - } - }; - window.addEventListener("message", eventHandler); - return () => { - window.removeEventListener("message", eventHandler); - }; - }, [resetAndSetGamePgn]); + return () => controller.abort(); + }, [ + gameFromUrl, + lichessGameId, + chessComUsername, + chessComGameId, + pgnParam, + orientationParam, + resetAndSetGamePgn, + ]); const isGameLoaded = gameFromUrl !== undefined || (!!game.getHeaders().White && game.getHeaders().White !== "?") || game.history().length > 0; - if (evaluationProgress) return null; - return ( - { - await router.replace( - { - query: {}, - pathname: router.pathname, - }, - undefined, - { shallow: true, scroll: false } - ); - resetAndSetGamePgn(game.pgn()); - }} - /> + <> + + + Loading shared game... + + + + + setLoadError("")} + severity="error" + variant="filled" + sx={{ width: "100%" }} + > + {loadError} + + + + {!evaluationProgress && ( + { + await router.replace( + { + query: {}, + pathname: router.pathname, + }, + undefined, + { shallow: true, scroll: false } + ); + resetAndSetGamePgn(game.pgn()); + }} + /> + )} + ); } diff --git a/src/sections/analysis/panelToolbar/index.tsx b/src/sections/analysis/panelToolbar/index.tsx index db518b9b..e9ca2e08 100644 --- a/src/sections/analysis/panelToolbar/index.tsx +++ b/src/sections/analysis/panelToolbar/index.tsx @@ -9,6 +9,7 @@ import SaveButton from "./saveButton"; import { useEffect } from "react"; import { ToolbarButton } from "@/components/ToolbarButton"; import { CopyPgnButton } from "./copyPgnButton"; +import { ShareButton } from "./shareButton"; export default function PanelToolBar() { const board = useAtomValue(boardAtom); @@ -69,6 +70,7 @@ export default function PanelToolBar() { {isSmOrGreater && ( <> + )} @@ -83,6 +85,7 @@ export default function PanelToolBar() { > + )} diff --git a/src/sections/analysis/panelToolbar/shareButton.tsx b/src/sections/analysis/panelToolbar/shareButton.tsx new file mode 100644 index 00000000..6016799d --- /dev/null +++ b/src/sections/analysis/panelToolbar/shareButton.tsx @@ -0,0 +1,83 @@ +import { useAtomValue } from "jotai"; +import { useEffect, useState } from "react"; +import { Alert, Snackbar } from "@mui/material"; +import { boardOrientationAtom, gameAtom } from "../states"; +import { ToolbarButton } from "@/components/ToolbarButton"; +import { buildPgnShareUrl } from "@/lib/shareLink"; + +export const ShareButton = () => { + const game = useAtomValue(gameAtom); + const boardOrientation = useAtomValue(boardOrientationAtom); + const [shareUrl, setShareUrl] = useState(""); + const [feedback, setFeedback] = useState<{ + message: string; + severity: "success" | "error"; + } | null>(null); + + // Building the link is async because the PGN is gzipped, and Safari drops the + // user activation across an await — clipboard writes from the click handler + // would fail with NotAllowedError. Prepare the link up front so the handler + // itself stays synchronous, like CopyPgnButton. + useEffect(() => { + if (game.history().length === 0) { + setShareUrl(""); + return; + } + + let cancelled = false; + + buildPgnShareUrl(game.pgn(), boardOrientation ? "white" : "black") + .then((url) => { + if (!cancelled) setShareUrl(url); + }) + .catch((error) => { + console.error("Unable to build share link", error); + if (!cancelled) setShareUrl(""); + }); + + return () => { + cancelled = true; + }; + }, [game, boardOrientation]); + + const handleShare = () => { + navigator.clipboard + ?.writeText?.(shareUrl) + ?.then(() => + setFeedback({ message: "Share link copied !", severity: "success" }) + ) + ?.catch((error) => { + console.error(error); + setFeedback({ + message: "Unable to copy the share link", + severity: "error", + }); + }); + }; + + return ( + <> + + + setFeedback(null)} + > + setFeedback(null)} + severity={feedback?.severity ?? "success"} + variant="filled" + sx={{ width: "100%" }} + > + {feedback?.message} + + + + ); +}; From 26db62c48aadb94a8aedb99a5ece6cf2c2fddf93 Mon Sep 17 00:00:00 2001 From: Mateus Ribeiro Date: Fri, 21 Aug 2026 19:05:09 -0300 Subject: [PATCH 2/4] feat : keep the loaded game in the address bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying the URL is what people actually do to share a position, so the analysis page now publishes the loaded game into the query string instead of only offering it behind the Share button. Links that already name a source are left alone. `?lichessGameId=x` resolves to the same game as a 1.5KB pgn blob and is far nicer to share, so only games with no source param get one written — pasted PGNs, games opened from the local database, and games finished on the play page. The publishing effect reruns when it changes the query, so it only settles because encoding is deterministic: CompressionStream zeroes the gzip MTIME field, so the same PGN always produces the same param and the equality guard holds. A param this component published is also recorded, so the loading effect does not treat it as a new link and reload the game underneath the user. Loading a shared link is now gated on a share param actually being present, rather than running the loader on every mount and relying on it falling through. --- .../analysis/panelHeader/loadGame.tsx | 65 +++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/src/sections/analysis/panelHeader/loadGame.tsx b/src/sections/analysis/panelHeader/loadGame.tsx index 355193bf..3db5cfff 100644 --- a/src/sections/analysis/panelHeader/loadGame.tsx +++ b/src/sections/analysis/panelHeader/loadGame.tsx @@ -1,5 +1,5 @@ import LoadGameButton from "../../loadGame/loadGameButton"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useChessActions } from "@/hooks/useChessActions"; import { boardAtom, @@ -15,7 +15,7 @@ import { useRouter } from "next/router"; import { GameEval } from "@/types/eval"; import { fetchLichessGame } from "@/lib/lichess"; import { fetchChessComGame } from "@/lib/chessCom"; -import { decodePgnParam } from "@/lib/shareLink"; +import { decodePgnParam, encodePgnParam } from "@/lib/shareLink"; import { Alert, Snackbar } from "@mui/material"; export default function LoadGame() { @@ -30,6 +30,10 @@ export default function LoadGame() { const [loadError, setLoadError] = useState(""); const [isLoadingSharedGame, setIsLoadingSharedGame] = useState(false); + // Params this component wrote itself, so the loading effect can tell them + // apart from a link the user actually opened. + const publishedPgnParamRef = useRef(undefined); + const joinedGameHistory = useMemo(() => game.history().join(), [game]); const resetAndSetGamePgn = useCallback( @@ -54,6 +58,14 @@ export default function LoadGame() { orientation: orientationParam, } = router.query; + const hasLichessParam = typeof lichessGameId === "string" && !!lichessGameId; + const hasChessComParams = + typeof chessComUsername === "string" && + !!chessComUsername && + typeof chessComGameId === "string" && + !!chessComGameId; + const hasPgnParam = typeof pgnParam === "string" && !!pgnParam; + useEffect(() => { const controller = new AbortController(); const isWhiteOrientation = orientationParam !== "black"; @@ -63,14 +75,14 @@ export default function LoadGame() { setLoadError(""); try { - if (typeof pgnParam === "string" && pgnParam) { + if (hasPgnParam) { const pgn = await decodePgnParam(pgnParam); if (!pgn) throw new Error("This shared link is invalid or corrupted"); resetAndSetGamePgn(pgn, isWhiteOrientation); return; } - if (typeof lichessGameId === "string" && lichessGameId) { + if (hasLichessParam) { const res = await fetchLichessGame(lichessGameId, controller.signal); if (typeof res !== "string") { throw new Error(`Unable to load Lichess game ${lichessGameId}`); @@ -79,12 +91,7 @@ export default function LoadGame() { return; } - if ( - typeof chessComUsername === "string" && - chessComUsername && - typeof chessComGameId === "string" && - chessComGameId - ) { + if (hasChessComParams) { const pgn = await fetchChessComGame( chessComUsername, chessComGameId, @@ -108,13 +115,22 @@ export default function LoadGame() { gameFromUrl.site === "Chesskit.org" && gameFromUrl.black.name === "You" ); resetAndSetGamePgn(gameFromUrl.pgn, orientation, gameFromUrl.eval); - } else { + } else if ( + // A pgn param this component published is already loaded in the board. + hasPgnParam && + pgnParam === publishedPgnParamRef.current + ) { + return; + } else if (hasPgnParam || hasLichessParam || hasChessComParams) { loadSharedGame(); } return () => controller.abort(); }, [ gameFromUrl, + hasPgnParam, + hasLichessParam, + hasChessComParams, lichessGameId, chessComUsername, chessComGameId, @@ -123,6 +139,33 @@ export default function LoadGame() { resetAndSetGamePgn, ]); + // Keep the address bar carrying the loaded game, so copying the URL shares it. + // Links that already name a source stay as they are — `?lichessGameId=x` is + // far nicer to share than a 1.5KB blob, and resolves to the same game. + useEffect(() => { + if (joinedGameHistory.length === 0) return; + if (hasLichessParam || hasChessComParams) return; + + let cancelled = false; + + encodePgnParam(game.pgn()) + .then((param) => { + if (cancelled || router.query.pgn === param) return; + + publishedPgnParamRef.current = param; + router.replace( + { pathname: router.pathname, query: { ...router.query, pgn: param } }, + undefined, + { shallow: true, scroll: false } + ); + }) + .catch((error) => console.error("Unable to publish share link", error)); + + return () => { + cancelled = true; + }; + }, [game, joinedGameHistory, hasLichessParam, hasChessComParams, router]); + const isGameLoaded = gameFromUrl !== undefined || (!!game.getHeaders().White && game.getHeaders().White !== "?") || From 9214ce17fa33bef8003c52f5a24928fd0921e17c Mon Sep 17 00:00:00 2001 From: Mateus Ribeiro Date: Fri, 21 Aug 2026 19:38:27 -0300 Subject: [PATCH 3/4] fix : bound decompression as it streams, not after it finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The size check ran on the finished string, which meant a decompression bomb was fully expanded before being rejected. gzip reaches ratios near 1000:1, so a 271KB param inflated to 200MB and 1.1GB RSS in ~180ms — enough to take a tab down long before the check could refuse the result. Decoding now reads the inflate stream chunk by chunk and cancels as soon as the output passes the cap, so the same shape of payload is abandoned mid-stream: a 60MB bomb sized to stay under the param cap is now rejected in 2ms with a 0.5MB heap delta. Param length is capped too, which bounds the work before any inflation starts. Also stop applying a decoded game after its effect run has been superseded. Unlike the fetches, decodePgnParam takes no AbortSignal and always resolves, so opening a second link while the first was still decoding could load the older game over the newer one. Every success path now checks the signal before touching the board, and the early-skip branch returns the cleanup like the others rather than dropping it. --- src/lib/shareLink.ts | 57 +++++++++++++++---- .../analysis/panelHeader/loadGame.tsx | 12 ++-- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/lib/shareLink.ts b/src/lib/shareLink.ts index 685086b2..cfbc2750 100644 --- a/src/lib/shareLink.ts +++ b/src/lib/shareLink.ts @@ -13,8 +13,13 @@ const FORMAT_GZIP = "1"; const FORMAT_RAW = "0"; -/** Guards against a hand-crafted URL trying to inflate a decompression bomb. */ -const MAX_DECODED_PGN_LENGTH = 500_000; +/** + * Decompression is bounded as it runs, not checked afterwards: gzip reaches + * ratios near 1000:1, so a ~270KB param can inflate to 200MB and take the tab + * down well before any check on the finished string could reject it. + */ +const MAX_DECODED_PGN_BYTES = 500_000; +const MAX_PARAM_LENGTH = 100_000; const isCompressionSupported = (): boolean => typeof CompressionStream === "function" && @@ -56,12 +61,43 @@ const gzip = async (value: string): Promise => { return new Uint8Array(await new Response(stream).arrayBuffer()); }; -const gunzip = async (bytes: Uint8Array): Promise => { - const stream = new Blob([bytes]) +/** + * Inflates gzip data, giving up as soon as the output passes `maxBytes` so a + * decompression bomb is abandoned mid-stream rather than fully expanded. + */ +const gunzipBounded = async ( + bytes: Uint8Array, + maxBytes: number +): Promise => { + const reader = new Blob([bytes]) .stream() - .pipeThrough(new DecompressionStream("gzip")); + .pipeThrough(new DecompressionStream("gzip")) + .getReader(); + + const chunks: Uint8Array[] = []; + let size = 0; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; - return new Response(stream).text(); + size += value.length; + if (size > maxBytes) { + await reader.cancel(); + return undefined; + } + + chunks.push(value); + } + + const decoded = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + decoded.set(chunk, offset); + offset += chunk.length; + } + + return new TextDecoder().decode(decoded); }; /** Encodes a PGN into the value used by the `pgn` query param. */ @@ -82,6 +118,8 @@ export const decodePgnParam = async ( param: string ): Promise => { try { + if (param.length > MAX_PARAM_LENGTH) return undefined; + const format = param.slice(0, 1); const payload = param.slice(1); if (!payload) return undefined; @@ -89,14 +127,13 @@ export const decodePgnParam = async ( const bytes = base64UrlToBytes(payload); if (format === FORMAT_RAW) { - const pgn = new TextDecoder().decode(bytes); - return pgn.length > MAX_DECODED_PGN_LENGTH ? undefined : pgn; + if (bytes.length > MAX_DECODED_PGN_BYTES) return undefined; + return new TextDecoder().decode(bytes); } if (format === FORMAT_GZIP) { if (!isCompressionSupported()) return undefined; - const pgn = await gunzip(bytes); - return pgn.length > MAX_DECODED_PGN_LENGTH ? undefined : pgn; + return await gunzipBounded(bytes, MAX_DECODED_PGN_BYTES); } return undefined; diff --git a/src/sections/analysis/panelHeader/loadGame.tsx b/src/sections/analysis/panelHeader/loadGame.tsx index 3db5cfff..f9edc573 100644 --- a/src/sections/analysis/panelHeader/loadGame.tsx +++ b/src/sections/analysis/panelHeader/loadGame.tsx @@ -77,6 +77,7 @@ export default function LoadGame() { try { if (hasPgnParam) { const pgn = await decodePgnParam(pgnParam); + if (controller.signal.aborted) return; if (!pgn) throw new Error("This shared link is invalid or corrupted"); resetAndSetGamePgn(pgn, isWhiteOrientation); return; @@ -84,6 +85,7 @@ export default function LoadGame() { if (hasLichessParam) { const res = await fetchLichessGame(lichessGameId, controller.signal); + if (controller.signal.aborted) return; if (typeof res !== "string") { throw new Error(`Unable to load Lichess game ${lichessGameId}`); } @@ -97,6 +99,7 @@ export default function LoadGame() { chessComGameId, controller.signal ); + if (controller.signal.aborted) return; resetAndSetGamePgn(pgn, isWhiteOrientation); } } catch (error) { @@ -116,12 +119,11 @@ export default function LoadGame() { ); resetAndSetGamePgn(gameFromUrl.pgn, orientation, gameFromUrl.eval); } else if ( - // A pgn param this component published is already loaded in the board. - hasPgnParam && - pgnParam === publishedPgnParamRef.current + // Skip a pgn param this component published itself: the board already + // holds that game, and reloading it would fight with the user. + !(hasPgnParam && pgnParam === publishedPgnParamRef.current) && + (hasPgnParam || hasLichessParam || hasChessComParams) ) { - return; - } else if (hasPgnParam || hasLichessParam || hasChessComParams) { loadSharedGame(); } From 196ce7d3aa2023c51cc4382d8f005de6d47b8d02 Mon Sep 17 00:00:00 2001 From: Mateus Ribeiro Date: Fri, 21 Aug 2026 19:42:05 -0300 Subject: [PATCH 4/4] feat : drop the Chess.com link format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `?pgn=` already covers Chess.com games, and every other source, without a lookup that can fail. The Chess.com format only helped someone hand-writing a link, who would need a username the game URL does not contain — and who could instead open the game and copy the address bar, which now carries the game. What it cost was the only failure mode in the feature: Chess.com exposes no single-game endpoint, so resolving an id meant scanning monthly archives. That scan had to be bounded — 8.9MB over six requests for an active player, against 150+ archives and a few hundred MB unbounded — which in turn meant games older than the window failed even though their archive existed. `?lichessGameId=` is untouched. --- src/lib/chessCom.ts | 55 ------------------- .../analysis/panelHeader/loadGame.tsx | 28 +--------- 2 files changed, 3 insertions(+), 80 deletions(-) diff --git a/src/lib/chessCom.ts b/src/lib/chessCom.ts index ed394aa2..73edcee7 100644 --- a/src/lib/chessCom.ts +++ b/src/lib/chessCom.ts @@ -50,61 +50,6 @@ export const getChessComUserRecentGames = async ( return gamesToReturn; }; -/** - * Chess.com's public API exposes no single-game endpoint, and the undocumented - * `/callback/live/game/{id}` route sends no CORS headers, so it is unreachable - * from the browser. The only way to resolve a game id is to scan that player's - * monthly archives. - * - * Archives run ~700KB each and an active player can have well over a hundred of - * them, so the scan is bounded to the most recent months and walks newest first - * — shared games are nearly always recent, so this usually resolves on the first - * request. - */ -export const CHESS_COM_ARCHIVE_SCAN_LIMIT = 6; - -export const fetchChessComGame = async ( - username: string, - gameId: string, - signal?: AbortSignal -): Promise => { - const usernameParam = encodeURIComponent(username.trim().toLowerCase()); - - const archivesRes = await fetch( - `https://api.chess.com/pub/player/${usernameParam}/games/archives`, - { method: "GET", signal } - ); - - if (archivesRes.status >= 400) { - throw new Error(`Chess.com user "${username}" not found`); - } - - const archivesData = await archivesRes.json(); - const archives: string[] = archivesData?.archives ?? []; - - const archivesToScan = archives - .slice(-CHESS_COM_ARCHIVE_SCAN_LIMIT) - .reverse(); - - for (const archiveUrl of archivesToScan) { - const res = await fetch(archiveUrl, { method: "GET", signal }); - if (res.status >= 400) continue; - - const data = await res.json(); - const games: ChessComGame[] = data?.games ?? []; - - const game = games.find( - (game) => game.uuid === gameId || game.url?.split("/").pop() === gameId - ); - - if (game?.pgn) return game.pgn; - } - - throw new Error( - `Game ${gameId} not found in ${username}'s last ${CHESS_COM_ARCHIVE_SCAN_LIMIT} months on Chess.com` - ); -}; - export const getChessComUserAvatar = async ( username: string ): Promise => { diff --git a/src/sections/analysis/panelHeader/loadGame.tsx b/src/sections/analysis/panelHeader/loadGame.tsx index f9edc573..22650a61 100644 --- a/src/sections/analysis/panelHeader/loadGame.tsx +++ b/src/sections/analysis/panelHeader/loadGame.tsx @@ -14,7 +14,6 @@ import { Chess } from "chess.js"; import { useRouter } from "next/router"; import { GameEval } from "@/types/eval"; import { fetchLichessGame } from "@/lib/lichess"; -import { fetchChessComGame } from "@/lib/chessCom"; import { decodePgnParam, encodePgnParam } from "@/lib/shareLink"; import { Alert, Snackbar } from "@mui/material"; @@ -52,18 +51,11 @@ export default function LoadGame() { const { lichessGameId, - chessComUsername, - chessComGameId, pgn: pgnParam, orientation: orientationParam, } = router.query; const hasLichessParam = typeof lichessGameId === "string" && !!lichessGameId; - const hasChessComParams = - typeof chessComUsername === "string" && - !!chessComUsername && - typeof chessComGameId === "string" && - !!chessComGameId; const hasPgnParam = typeof pgnParam === "string" && !!pgnParam; useEffect(() => { @@ -90,17 +82,6 @@ export default function LoadGame() { throw new Error(`Unable to load Lichess game ${lichessGameId}`); } resetAndSetGamePgn(res, isWhiteOrientation); - return; - } - - if (hasChessComParams) { - const pgn = await fetchChessComGame( - chessComUsername, - chessComGameId, - controller.signal - ); - if (controller.signal.aborted) return; - resetAndSetGamePgn(pgn, isWhiteOrientation); } } catch (error) { if (controller.signal.aborted) return; @@ -122,7 +103,7 @@ export default function LoadGame() { // Skip a pgn param this component published itself: the board already // holds that game, and reloading it would fight with the user. !(hasPgnParam && pgnParam === publishedPgnParamRef.current) && - (hasPgnParam || hasLichessParam || hasChessComParams) + (hasPgnParam || hasLichessParam) ) { loadSharedGame(); } @@ -132,10 +113,7 @@ export default function LoadGame() { gameFromUrl, hasPgnParam, hasLichessParam, - hasChessComParams, lichessGameId, - chessComUsername, - chessComGameId, pgnParam, orientationParam, resetAndSetGamePgn, @@ -146,7 +124,7 @@ export default function LoadGame() { // far nicer to share than a 1.5KB blob, and resolves to the same game. useEffect(() => { if (joinedGameHistory.length === 0) return; - if (hasLichessParam || hasChessComParams) return; + if (hasLichessParam) return; let cancelled = false; @@ -166,7 +144,7 @@ export default function LoadGame() { return () => { cancelled = true; }; - }, [game, joinedGameHistory, hasLichessParam, hasChessComParams, router]); + }, [game, joinedGameHistory, hasLichessParam, router]); const isGameLoaded = gameFromUrl !== undefined ||