feat : shareable game links - #99
Open
mateuskb wants to merge 4 commits into
Open
Conversation
added 4 commits
August 21, 2026 19:00
Adds a Share button to the analysis toolbar and three URL formats the
analysis page can load a game from.
`?pgn=<gzip+base64url>` 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=<user>&chessComGameId=<id>` 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=<id>` 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.
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.
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.
`?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.
|
Dups #97 (another approch with compression lib) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds shareable links for the analysis page, plus a Share button in the toolbar.
?pgn=<gzip+base64url>?lichessGameId=<id>Both accept
&orientation=black.The analysis page also writes the loaded game into the query string as you go, so copying the address bar shares it without pressing anything. Links that already name a source are left alone —
?lichessGameId=xresolves to the same game and is much nicer to share than a 1.5KB blob, so only games with no source param get one written (pasted PGNs, games opened from the local database, games finished on/play).Why
?pgn=carries the whole gameChesskit is a static export with no backend, so a self-contained link is the only format that cannot fail to resolve, and it behaves the same whatever the game's origin.
Compression uses the browser's
CompressionStream, so no dependency is added. Measured on real games:There is a raw base64url fallback for browsers without
CompressionStream, and a one character format tag so the encoding can change later without breaking links already shared.Details worth flagging for review
The publishing effect terminates because encoding is deterministic. It reruns when it changes the query, so it only settles because
CompressionStreamzeroes the gzip MTIME field — the same PGN always yields the same param and the equality guard holds. I verified this at the byte level (1f8b08 00000000) across runs seconds apart. A param the component published is also recorded in a ref so the loading effect does not treat it as a new link and reload the game underneath the user.The share link is built ahead of the click, not inside the handler. Compressing is async and Safari drops user activation across an
await, so writing to the clipboard afterwards fails withNotAllowedError. Preparing it up front keeps the handler synchronous, matching howCopyPgnButtonalready works. This was a real failure observed in Safari, not a theoretical one.Loading is gated on a share param actually being present, rather than running the loader on every mount and relying on it falling through every branch.
Safety
Malformed links land on an empty board instead of crashing, and in-flight work is discarded when the params change — including the decode path, which takes no
AbortSignaland would otherwise be able to apply an older game over a newer one.Decompression is bounded as it streams rather than checked afterwards. gzip reaches ratios near 1000:1, so a finished-string check is not a guard at all: a 271KB param inflates to 200MB and 1.1GB RSS in ~180ms, which takes the tab down long before any check on the result could refuse it. Decoding now reads the inflate stream chunk by chunk and cancels once the output passes the cap, and param length is capped so the work is bounded before inflation starts.
Testing
npm run lintpasses, which type-checks as well. No test suite exists in the repo, so I exercised the compiled modules directly:"","1","9abc","1notbase64!!!","1AAAA","zzzz") all return undefined rather than throwingVerified manually in the browser for both formats.
History of this PR
Two things changed after this was first opened, both worth calling out rather than burying in the commit log:
fix : bound decompression as it streamscorrects it.?chessComUsername=&chessComGameId=) was included and has since been removed. Chess.com exposes no single-game endpoint, so it meant scanning monthly archives — bounded to six months at ~8.9MB for an active player, against 150+ archives unbounded — and it failed for older games.?pgn=covers Chess.com games anyway, so the lookup was cost without benefit.src/lib/chessCom.tsis untouched by this PR.Happy to split the address-bar commit out from the share-link commit if you would rather take them separately, or to squash the four commits into two if you prefer a clean history.