Discord presence now updates as it happens

The home page presence card polled Lanyard every 60 seconds. It is now the site's first Svelte island, holding one Lanyard WebSocket, so a status, game or Spotify change appears as Discord reports it — and the page still ships the same server-rendered card before any JavaScript runs.

The presence card in the Watch Me Live section used to ask Lanyard’s REST API for Discord status once a minute, from vanilla JavaScript in site-v18.js, while the card was on screen. A change could take up to a minute to show, and every visitor paid for a request each minute whether anything had changed or not.

Lanyard also offers a WebSocket that pushes presence the moment Discord reports it. Using it well needs state that lives as long as the connection — a heartbeat, a reconnect schedule, the last known presence — which is exactly what a small component framework is for. So this release adds Svelte to the Astro build and makes the card its first island.

What a visitor gets

The card looks the same and starts the same: Astro renders the Svelte component at build time, so the delivered HTML contains the avatar, name, status pill and the familiar “Live status loads when this section comes into view” line before any script runs. When the card comes within 250 pixels of the viewport — the same threshold the old observer used — the island hydrates, opens one socket, and fills in. From then on updates arrive as they happen rather than on the next poll.

Do not disturb now reads as “Do not disturb” rather than “Dnd”.

How a static page hosts an island

Each page on this site is one authored HTML document that Astro injects whole, so there was nowhere to put a component. Page sources can now leave a marker, <!--island:lanyard-presence-->, and PageDocument.astro renders the matching named slot at that spot. The home page passes the presence component into that slot; every other page is untouched.

The marker sits inside the existing .presence-line box rather than replacing it. Astro wraps islands in an <astro-island> element with display: contents, and the section’s reveal transition and stacking are written as .scene-copy > *. Had the island been that direct child, the card would have skipped the fade-in its neighbours get. With the box kept in the page, the grid still lays out the avatar and text, and the only stylesheet change is .presence-line > img becoming .presence-line img.

How the connection behaves

  • It speaks Lanyard’s documented protocol: wait for the hello, subscribe to one user ID, and send a heartbeat on the interval the hello names.
  • A hidden tab closes the socket and a returning one reopens it. Nothing is lost, because Lanyard sends the full current presence on every new subscription, and an open socket would otherwise keep the page out of the browser’s back/forward cache.
  • If the connection drops, the last known card stays on screen while it reconnects, waiting 1, 2, 4 seconds and so on up to a minute, with jitter so a Lanyard restart does not bring every open tab back in the same instant.
  • Until the first presence arrives, a failed socket falls back to one REST request, so the card never sits on its loading text. A browser without WebSockets keeps the old 60-second poll.

The protocol handling and every string the card can show live in src/lib/lanyard-presence.js, which has no DOM or network code, so tools/test-lanyard-presence.mjs checks it directly.

Four things that had to change around it

  • The security policy. connect-src allowed https://api.lanyard.rest but not wss://api.lanyard.rest, so the browser would have blocked the socket. It now allows both.
  • The per-page CSS purge. It keeps classes that scripts add at runtime by scanning public/assets/js. The island sets .status-pill.online and .offline from its own bundle, which the purge never read, so those rules would have been deleted from every page. It now scans the island bundles in dist/_astro as well, and tools/test-live-islands.mjs fails if either rule goes missing from the home page.
  • The release audit. It compares each page’s visible text with its source, and the card’s text now comes from the component, not home.html. Islands are excluded from that comparison the same way the shared header and footer are, and the built card’s text is asserted by tools/test-live-islands.mjs instead.
  • Sitemap dates. The home route’s date is derived from the inputs that render it. The component now renders part of that page, so its files count as inputs for /.

Cost

The island loads only when the card nears view: 2.1 KB for the component, 0.5 KB for Astro’s Svelte adapter and 11.7 KB for the Svelte runtime, gzipped. In exchange the presence code left the main script every page loads, which went from 5.2 KB to 4.5 KB gzipped.

Verification

  • npm run test:delivery: 181 of 181, including ten new presence checks. Before the build, two sitemap-date checks failed, as they should: the committed dates were stale against the edited sources until prebuild regenerated them.
  • npm run build, then npm run test:worker (77 of 77), npm run test:built (272 of 272, including three new island checks) and npm run check:feeds (social, community and live).
  • The release audit in tools/audit.py first failed — “index.html: visible authored text changed during Astro build” — for the reason above, and passed across all 36 canonical pages, this one included, once islands were excluded from the text comparison.
  • Against npm run preview: npm run test:browser, 0 failures across 36 routes, and npm run test:a11y, 0 failures across 82 checks.
  • tools/indexing-surface.json was rewritten deliberately; its only changes are this page’s route and sitemap entry.
  • In headless Chrome against the same preview: no socket before the card was scrolled to; after it, a hello with a 30-second heartbeat, one subscribe frame and the initial state, rendering live presence with the Discord avatar. Hiding the tab closed the socket with code 1000 and opened no other; showing it opened a new one that received a fresh initial state; closing the socket unexpectedly brought a third back on its own. Zero REST calls, zero CSP violations, no console errors, and the card laid out correctly at 1280 and 375 pixels wide.

All eight authored routes carry a new sitemap date, because PageDocument.astro and site-v18.css — both inputs to every page — changed.