Skip to content

perf(featured-stream): stop probing Twitch on every page load (avoid rate limits) - #4810

Merged
evanpelle merged 2 commits into
openfrontio:mainfrom
Zixer1:feat/featured-stream-server-liveness
Jul 31, 2026
Merged

perf(featured-stream): stop probing Twitch on every page load (avoid rate limits)#4810
evanpelle merged 2 commits into
openfrontio:mainfrom
Zixer1:feat/featured-stream-server-liveness

Conversation

@Zixer1

@Zixer1 Zixer1 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

The featured-stream panel no longer works out liveness itself. featured-stream.json now lists only channels the API's Helix cron sees live, in priority order, so channels[0] is simply what to embed. Nothing Twitch-related loads until there is something to show.

Pairs with openfrontio/infra#488.

Why

Liveness was detected by constructing a real autoplaying Twitch.Player for each configured channel and reading its ONLINE/OFFLINE events, walking the list until one was live. With 3 channels configured and none live, that boots three players behind an opacity-0 panel.

Meaning many requests were constantly being sent out.. That is not page breaking considering not much is happening on the main page. But still not the cleanest architecture..

Result

Same measurement method, after:

Scenario Before After
Nobody live 327 0 — the SDK is never even fetched
Stale config, listed channel just went offline 327 125 — one player boot, then the panel hides

An offline period now costs one small JSON poll per minute against our own API (60s edge cache) instead of a player mount per channel.

Correctness

The mounted player is still watched, so this does not depend on the served config being perfectly fresh:

  • OFFLINE / ENDED hides the panel and resumes polling. Covers a stream ending mid-session.
  • An offline-at-load channel fires READY with getEnded() already true (no OFFLINE), read synchronously as before. Covers the ~3 min worst case staleness of the served config (2 min cron + 60s edge cache).

Unchanged: the CrazyGames/desktop-shell exclusion, the ad-free close and its per-day persistence, minimize, drag/corner snapping, flick-to-dismiss, and staying up through the lobby wait.

Two side benefits:

  • The embed is now only ever constructed when it will be visible, which sits better with Twitch's rule against obscured embeds. Previously an offline player sat on the page hidden.
  • Visitors' IPs and player fingerprints only reach Twitch when a stream is actually being shown to them.

Deploy order

Merge openfrontio/infra#488 first. If this ships first the API still serves the unfiltered list; the panel then embeds channels[0] and hides it if it turns out to be offline, so nothing breaks, but the multi-channel walk is gone until infra lands (a lower-priority live channel would not be picked up).

Tests

npx vitest run tests/client — 65 files, 777 passed.

New tests/client/components/FeaturedStreamPanel.test.ts mounts the component with a fake SDK and asserts the regression directly:

  • no Twitch.Player is constructed when the API reports nobody live (and none when the feature is off)
  • exactly one player for the live channel, not one per configured channel
  • panel appears once the player reports playing
  • panel hides on a stale config, and on a stream ending mid-session

Plus channelToEmbed unit tests for the config-to-channel decision.

Liveness was detected in the browser by constructing a real autoplaying
Twitch.Player for each configured channel and reading its ONLINE/OFFLINE events.
Measured on a cold load with 3 channels configured and none live: 327 Twitch
requests, for a panel that sat at opacity:0 the whole time.

featured-stream.json now lists only channels the API's Helix cron sees live, in
priority order, so channels[0] is simply what to embed. Nobody live is 0 Twitch
requests (the SDK never loads), and an offline period costs one small JSON poll
per minute instead of a player mount per channel.

The mounted player is still watched: OFFLINE/ENDED hides the panel, which covers
a stream ending mid-session and the ~3 min worst-case staleness of the served
config (2 min cron + 60s edge cache). Dropping the probe also means the embed is
only ever constructed when it will be visible.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The featured stream component now selects live channels from the application API instead of using client-side probing. It stores the selected channel as reactive state, polls the API every minute when no player displays or when gameplay pauses, mounts one player for the selected channel, and resumes polling after player offline, ended, or mount failure events. The channelToEmbed helper returns the first API-provided channel when enabled or null when disabled or no channels are available.

Changes

Featured stream lifecycle

Layer / File(s) Summary
Live-channel selection and reactive state
src/client/FeaturedStream.ts
The component adds channelToEmbed to convert configuration to a channel string, imports FeaturedStreamConfig, and stores the API-selected channel as reactive state instead of maintaining a channel array and index.
Polling, mounting, and lifecycle management
src/client/FeaturedStream.ts
The component starts API polling after mount, clears polling during gameplay, mounts the selected player asynchronously, resumes polling after offline or ended events, simplifies player event handlers to hide the stream and resume polling, and centralizes polling cleanup when dismissed.
Implementation validation
tests/client/components/FeaturedStream.test.ts, tests/client/components/FeaturedStreamPanel.test.ts
Tests verify that channelToEmbed returns null when disabled or channels unavailable and returns the first channel when enabled. Panel tests mock the API and Twitch player to verify no player creation when disabled, player creation from API responses, visibility after ready state, stale stream removal, ended stream handling, polling continuation during wait states, and prevention of reopening after dismissal.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FeaturedStream
  participant getFeaturedStream
  participant TwitchPlayer
  FeaturedStream->>getFeaturedStream: Poll for live channels
  getFeaturedStream-->>FeaturedStream: Return selected channel
  FeaturedStream->>TwitchPlayer: Mount selected channel
  TwitchPlayer-->>FeaturedStream: READY event
  FeaturedStream-->>FeaturedStream: Display panel
  TwitchPlayer-->>FeaturedStream: ENDED or offline event
  FeaturedStream->>getFeaturedStream: Resume polling
Loading

Possibly related PRs

  • openfrontio/OpenFrontIO#4335: Both PRs modify FeaturedStream.ts to implement the featured Twitch stream panel with channel selection and liveness logic.
  • openfrontio/OpenFrontIO#4696: Both PRs modify the render() method in FeaturedStream.ts to update featured stream display and template constraints.
  • openfrontio/OpenFrontIO#4698: Both PRs make substantial modifications to the featured stream lifecycle with API-driven channel selection and visibility handling.

Suggested labels: Feature

Suggested reviewers: evanpelle

Poem

API channels arrive,
One player claims the stage,
Polls wait while streams play,
Ended streams fade to rest,
Then the API calls again.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main performance change: avoiding repeated Twitch probing on page load to reduce rate limits.
Description check ✅ Passed The description directly explains the API-driven liveness change, performance impact, correctness behavior, deployment order, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Zixer1 Zixer1 added this to the v34 milestone Jul 31, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 31, 2026
@Zixer1 Zixer1 modified the milestones: v34, v33 Jul 31, 2026
mountPlayer awaits the Twitch SDK, and only re-checked dismissed/inGame
before that await. Closing the panel during the load then fell through to
the "no mount node" branch and scheduled a poll. Harmless in practice
(poll() early-returns once dismissed) but it left a timer behind.

Adds tests for the close path: closing removes the panel and stops
polling entirely, and it stays gone when a game ends. Includes a positive
control showing the same 10 minute jump does keep polling while the panel
is waiting for someone to go live, so the "stopped" assertion has teeth.

Minimize deliberately keeps streaming, since Twitch disallows an obscured
embed, so the minimized panel stays a visible thumbnail.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/FeaturedStream.ts (1)

255-271: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the SDK-load failure path against a mid-load game start.

The catch block checks only this.dismissed before calling schedulePoll(). The success path a few lines below (line 270) checks both this.inGame and this.dismissed, and the comment right above it states the poll must not stay scheduled behind a game start. If the SDK load fails while a game has started in the meantime, this branch still calls schedulePoll(), which contradicts that stated invariant.

Match the guard used on line 270 so both paths handle the same race consistently.

🐛 Proposed fix
     } catch (e) {
       // SDK blocked (extension, network): try again on the next poll rather than never.
       console.error("featured-stream: Twitch SDK load failed", e);
-      if (this.dismissed) return;
+      if (this.inGame || this.dismissed) return;
       this.goOffline();
       this.schedulePoll();
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/FeaturedStream.ts` around lines 255 - 271, Update the SDK-load
failure catch block in mountPlayer to guard the retry path with both this.inGame
and this.dismissed, matching the success-path guard; when either state is
active, return without calling goOffline or schedulePoll.
🧹 Nitpick comments (1)
src/client/FeaturedStream.ts (1)

272-309: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Handle a Twitch.Player construction failure the same way as an SDK load failure.

loadTwitchSdk() failures are caught and recovered with goOffline() + schedulePoll(), but new Twitch.Player(...) itself is not wrapped in a try/catch. If the constructor throws (bad host state, invalid options, or an SDK-internal error), mountPlayer rejects. Since poll() is invoked as void this.poll() from several callers, this becomes an unhandled promise rejection, and no schedulePoll() runs afterward, so the panel stops recovering until an unrelated event (leave-lobby) triggers a fresh poll().

Wrap the construction in a try/catch that mirrors the SDK-load failure recovery, so an external-call failure here degrades gracefully instead of silently stalling.

♻️ Proposed fix
     this.teardownPlayer(); // destroy the previous player so its listeners can't fire
     host.innerHTML = "";
     const gen = ++this.mountGen;
     const fresh = () => gen === this.mountGen; // ignore events from a superseded mount
-    const player = new Twitch.Player(host, {
-      channel,
-      parent: [window.location.hostname], // bare host; self-adapts to any domain/subdomain
-      muted: true, // required for autoplay
-      autoplay: true,
-      width: "100%",
-      height: "100%",
-    });
+    let player: TwitchPlayer;
+    try {
+      player = new Twitch.Player(host, {
+        channel,
+        parent: [window.location.hostname], // bare host; self-adapts to any domain/subdomain
+        muted: true, // required for autoplay
+        autoplay: true,
+        width: "100%",
+        height: "100%",
+      });
+    } catch (e) {
+      console.error("featured-stream: Twitch player construction failed", e);
+      if (this.inGame || this.dismissed) return;
+      this.goOffline();
+      this.schedulePoll();
+      return;
+    }
     this.player = player;
     const P = Twitch.Player;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/FeaturedStream.ts` around lines 272 - 309, Wrap the Twitch.Player
construction in mountPlayer with try/catch, covering the new Twitch.Player(host,
options) call before assigning this.player or registering listeners. In the
catch path, mirror loadTwitchSdk failure recovery by calling goOffline() and
schedulePoll(), then return without continuing initialization.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/client/FeaturedStream.ts`:
- Around line 255-271: Update the SDK-load failure catch block in mountPlayer to
guard the retry path with both this.inGame and this.dismissed, matching the
success-path guard; when either state is active, return without calling
goOffline or schedulePoll.

---

Nitpick comments:
In `@src/client/FeaturedStream.ts`:
- Around line 272-309: Wrap the Twitch.Player construction in mountPlayer with
try/catch, covering the new Twitch.Player(host, options) call before assigning
this.player or registering listeners. In the catch path, mirror loadTwitchSdk
failure recovery by calling goOffline() and schedulePoll(), then return without
continuing initialization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 229b69b3-d4ec-44b2-a398-a6946c3fdb48

📥 Commits

Reviewing files that changed from the base of the PR and between d99b178 and 2c5dba9.

📒 Files selected for processing (2)
  • src/client/FeaturedStream.ts
  • tests/client/components/FeaturedStreamPanel.test.ts

@evanpelle
evanpelle merged commit cca92b2 into openfrontio:main Jul 31, 2026
10 checks passed
@github-project-automation github-project-automation Bot moved this from Triage to Complete in OpenFront Release Management Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

2 participants