Skip to content

feat : shareable game links - #99

Open
mateuskb wants to merge 4 commits into
GuillaumeSD:mainfrom
mateuskb:feat/shareable-game-links
Open

feat : shareable game links#99
mateuskb wants to merge 4 commits into
GuillaumeSD:mainfrom
mateuskb:feat/shareable-game-links

Conversation

@mateuskb

@mateuskb mateuskb commented Aug 21, 2026

Copy link
Copy Markdown

What

Adds shareable links for the analysis page, plus a Share button in the toolbar.

Format Notes
?pgn=<gzip+base64url> Carries the game in the link itself
?lichessGameId=<id> Already existed, now on the same loading and error path

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=x resolves 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 game

Chesskit 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:

PGN URL
Typical Chess.com game, 3120 chars 1521 chars
125-move annotated Lichess game, 7556 chars 3310 chars

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 CompressionStream zeroes 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 with NotAllowedError. Preparing it up front keeps the handler synchronous, matching how CopyPgnButton already 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 AbortSignal and 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 lint passes, which type-checks as well. No test suite exists in the repo, so I exercised the compiled modules directly:

  • Encode/decode, 13 cases: real Chess.com PGN round-trip, unicode player names, URL-safe alphabet, minimal games
  • 6 malformed inputs ("", "1", "9abc", "1notbase64!!!", "1AAAA", "zzzz") all return undefined rather than throwing
  • Decompression bomb sized to stay under the param cap, so the streaming guard is what has to catch it: 60MB payload rejected in 2ms with a 0.5MB heap delta

Verified 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:

  • The bomb guard originally ran on the finished string, so the safety claim in the first version of this description did not actually hold. fix : bound decompression as it streams corrects it.
  • A Chess.com format (?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.ts is 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.

Mateus Ribeiro 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.
@yluom

yluom commented Aug 22, 2026

Copy link
Copy Markdown

Dups #97 (another approch with compression lib)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants