Building source adapters for live telemetry
How separate adapters turn Spotify and GitHub responses into small typed snapshots, each with its own polling, privacy, caching, and rate-limit rules.
I do not want a live telemetry panel to hide an API client inside a UI component. A small adapter should fetch each source and decide how fresh its data must be, which fields to keep, how long to retain them, and what to publish after a failure.
Spotify and GitHub are useful contrasting examples. Spotify playback can become misleading within seconds and may not exist at all. GitHub contribution data changes slowly, can be derived from one larger response, and is worth caching across a process restart. Both sources still need to reach the same browser-side stats layer.
The adapter deals with the external system. The panel renders the result. If a provider goes down, its adapter decides what to publish and the panel keeps the same input contract.
Start with the output contract
The common module interface is intentionally small:
export type StatModule<T> = {
start: (...args: any[]) => void;
getLatest: () => T;
getHistory: () => T[];
getVersion: () => number;
};
Each module has one job: publish snapshots when its source changes. getLatest feeds the current state, getHistory supplies the initial view, and getVersion lets the SSE route detect changes without sending the same value repeatedly.
The generic interface does not make the data interchangeable. Each source has its own schema in shared/stats. Valibot validates persisted GitHub data before it is used, while the shared types keep server code, transport serialization, and Solid stores aligned.
The public snapshot is also a security boundary. It should contain exactly what the panel needs. Provider credentials, raw API responses, device metadata, and control methods stay on the server.
Give every source its own clock
Polling should match what the visitor sees:
| Source | Useful freshness | Local policy | Persistence |
|---|---|---|---|
| Spotify | A few seconds while playing | 2.5 s while active, 15 s while idle | In-memory history only |
| GitHub | Several minutes | 30 min after success | Validated disk snapshot |
A shared five-minute timer would be wrong for both. It would make Spotify feel stale while asking GitHub for data more often than the UI needs.
Adapter 1: reduce Spotify to a safe snapshot
The Spotify integration has two authentication steps. The refresh token is kept in server environment variables. server/stats/spotify.ts exchanges it for a short-lived access token and keeps that access token in memory until one minute before its reported expiry:
if (tokenCache && tokenCache.expiresAt > Date.now() + TOKEN_EXPIRY_SAFETY_MARGIN_MS) {
return tokenCache.accessToken;
}
const response = await fetch(SPOTIFY_TOKEN_ENDPOINT, {
method: "POST",
headers: {
authorization: createBasicAuthHeader(clientId, clientSecret),
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
The browser never sees the refresh token or client secret. After authentication, the adapter maps the provider response to the fields that the panel can render:
type SpotifyNowPlaying = {
isConfigured: boolean;
isPlaying: boolean;
trackId: string | null;
trackName: string | null;
artistNames: string[];
albumName: string | null;
trackUrl: string | null;
progressMs: number;
durationMs: number;
fetchedAt: number;
};
This normalization handles three different “empty” cases without making the UI understand Spotify’s response format:
- HTTP
204means there is no current playback. - A non-track item is reduced to the same empty snapshot.
- Missing credentials produce
isConfigured: falseinstead of an exception in the stats stream.
The polling delay follows the current snapshot rather than staying fixed:
function getPollIntervalMs(isPlaying: boolean) {
return isPlaying ? 2_500 : 15_000;
}
When Spotify responds with 429, the adapter reads Retry-After and falls back to 30 seconds. A rejected access token invalidates the in-memory token cache so the next attempt can refresh it. Other request failures publish an empty configured snapshot and continue at the slower interval.
Playback history stays in memory. The module keeps at most 84 snapshots, enough for the panel to show a previous track without writing listening activity to a database. A restart clears the history.
Adapter 2: derive GitHub metrics once
The GitHub integration uses the opposite strategy. server/stats/github.ts requests the year-to-date contribution calendar with one GraphQL query, then derives all panel values locally:
The derivation uses ISO date strings as keys. That gives the adapter a stable comparison format for the year and month boundaries, while the 30-day series is generated from the local date window so days without contributions still appear as zeroes.
The resulting snapshot contains only display-ready aggregates:
type GitHubCommitStats = {
isConfigured: boolean;
username: string;
lastCommitDate: string | null;
commitsToday: number;
commitsLast30Days: number[];
commitsLast30DayLabels: string[];
commitsThisMonth: number;
commitsThisYear: number;
fetchedAt: number;
};
The panel uses “commits” as shorthand for GitHub’s contribution calendar. It is not a local git log or an audit of every commit.
Cache only the normalized state
GitHub data is slow enough to survive a restart. After a successful fetch, the adapter writes the normalized snapshot to github-cache.json in the application data directory. On startup it:
- Checks whether the file exists.
- Parses it with the shared Valibot schema.
- Uses it only while it is younger than the 30-minute polling interval.
- Schedules the next request for the remaining freshness window.
The cache stores the normalized object, so the rest of the application never has to interpret the provider response again. Valibot turns an old schema or malformed file into a cache miss instead of letting it enter the stream.
The error policy separates “try later” from “show a new state.” After a rate limit, the adapter reads X-RateLimit-Reset and preserves the latest valid snapshot until then. If the header is missing, it waits 15 minutes. Other failures publish an empty configured snapshot and retry on the slower interval. The panel does not present stale data as fresh, and a temporary rate limit does not erase the last valid value.
Keep the panel unaware of the provider
The Solid panels consume the normalized snapshots through the stats transport. They do not refresh credentials, parse Retry-After, read a cache file, or know how often a provider should be polled.
Each adapter now contains all decisions specific to its source:
| Concern | Spotify adapter | GitHub adapter |
|---|---|---|
| Authentication | Refresh token exchanged in memory | Server token in the GraphQL request |
| Normalization | Current track or explicit empty state | Contribution days into aggregate metrics |
| Freshness | Adaptive active/idle polling | Fixed 30-minute polling |
| Rate limit | Retry-After |
X-RateLimit-Reset |
| Retention | 84 in-memory snapshots | 84 in-memory snapshots plus one fresh disk snapshot |
| Browser payload | Track metadata and public URL | Aggregated contribution values |
The two providers follow different clocks and failure rules, but both publish a small typed snapshot that the UI can render directly.
The history endpoint can load both modules through that contract. The SSE route emits only changed versions, and the panels render state without repeating either provider’s API logic.