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-srcallowedhttps://api.lanyard.restbut notwss://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.onlineand.offlinefrom its own bundle, which the purge never read, so those rules would have been deleted from every page. It now scans the island bundles indist/_astroas well, andtools/test-live-islands.mjsfails 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 bytools/test-live-islands.mjsinstead. - 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 untilprebuildregenerated them.npm run build, thennpm run test:worker(77 of 77),npm run test:built(272 of 272, including three new island checks) andnpm run check:feeds(social, community and live).- The release audit in
tools/audit.pyfirst 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, andnpm run test:a11y, 0 failures across 82 checks. tools/indexing-surface.jsonwas 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.
