Proposal — Pusher Split Architecture: Server-Send / Client-Listen
Date: April 2026
Status: Proposed
Scope: frontend/src/hooks/usePusher.ts, api/src/pusher.ts
1. Current state
The architecture is already 90% correct. Roles are already split:
| Side | What it does today |
|---|---|
API Worker (api/src/pusher.ts) | Triggers events via Pusher REST HTTP API — no SDK, raw fetch with HMAC auth |
API Worker (api/src/server.ts) | Signs private-channel auth tokens (POST /api/pusher/auth) |
Browser (frontend/src/hooks/usePusher.ts) | Subscribes to channels and listens for events via pusher-js |
The remaining problem is usePusher.ts uses a static top-level import:
import Pusher from "pusher-js"; // ← pulled into SSR bundle, crashes CF WorkersThis already caused a deployment failure (window is not defined). The workaround applied was a dynamic import() inside useEffect, but the architecture itself is the right model — it just needs to be made explicit and enforced.
2. Proposed architecture
┌──────────────────────────────────────────────┐
│ Pusher Channels (3rd party) │
│ │
│ public channel: "live-status" │
│ private channel: "private-streamer-{id}" │
│ private channel: "private-operator-{id}" │
└──────────┬───────────────────────────────────┘
│ ▲
subscribe │ │ trigger (REST HTTP)
& listen │ │ HMAC-signed
▼ │
┌────────────┐ ┌──────────────┐
│ Browser │ │ API Worker │
│ (pusher-js │ │ (no SDK — │
│ dynamic │ │ raw fetch) │
│ import) │ └──────────────┘
└────────────┘
│
auth handshake only:
POST /api/pusher/auth
(private channels only)Rule: pusher-js is a browser-only dependency. The server never imports it.
3. Server side — what stays, what improves
What already works correctly
api/src/pusher.ts uses no SDK. It calls the Pusher REST API directly with a raw fetch + HMAC-SHA256 signature. This is correct and should not change.
// api/src/pusher.ts — correct today
export async function pusherTrigger(env, channel, event, data) {
// raw HTTP POST to https://api-{cluster}.pusher.com/apps/{id}/events
// HMAC-SHA256 signed — no pusher-server SDK
}
export function pusherSign(env, socketId, channelName) {
// signs private channel auth tokens for POST /api/pusher/auth
}Minor improvement: batch triggers
When the same mutation notifies both parties (operator + streamer), two sequential HTTP calls are made. Pusher supports batch events (/batch_events) — send both in one round-trip:
// proposed addition to pusher.ts
export async function pusherTriggerBatch(
env: PusherEnv,
events: { channel: string; event: string; data: unknown }[],
): Promise<void>All dual-notify sites in offers.ts and negotiations.ts become one call instead of two. Cleaner and halves the outbound requests on busy mutations.
4. Client side — enforced constraints
Rule: pusher-js is dynamically imported inside hooks only
pusher-js must never appear as a static top-level import anywhere in the frontend. The module calls window at load time and will crash the CF Pages SSR bundle.
Enforced via ESLint (proposed rule in eslint.config.js):
// eslint.config.js addition
{
rules: {
"no-restricted-imports": ["error", {
paths: [{
name: "pusher-js",
message: "pusher-js must be dynamically imported inside useEffect — never at module top level.",
}],
}],
},
}Current hook structure (correct after the fix)
usePusher.ts
├── getClient(actorType, actorId) — async, lazy new Pusher() via dynamic import()
├── getPublicClient() — async, lazy new Pusher() via dynamic import()
├── usePublicPusherChannel(channel, handlers) — useEffect → getPublicClient()
└── usePusherChannel(actor, channel, handlers) — useEffect → getClient()Neither getClient nor getPublicClient is ever called at module load time — only inside useEffect, which never runs during SSR.
Channel → hook mapping
| Channel | Hook | Used in |
|---|---|---|
live-status (public) | usePublicPusherChannel | StreamersList.tsx |
private-streamer-{id} | usePusherChannel | ProfileLayout.tsx |
private-operator-{id} | usePusherChannel | OperatorLayout.tsx |
Auth flow (private channels only)
The pusher-js client automatically calls POST /api/pusher/auth when subscribing to a private-* channel. This is the only server call the browser makes directly to the API for Pusher:
Browser subscribes to "private-streamer-42"
→ pusher-js calls POST /api/pusher/auth (credentials: include, session cookie)
→ API verifies session, signs auth token with PUSHER_SECRET
→ Returns { auth: "key:signature" }
→ pusher-js completes handshake with Pusher ChannelsNo changes needed here — this is already working correctly.
5. What does NOT change
| Concern | Status |
|---|---|
api/src/pusher.ts (trigger + sign) | Correct — no changes needed |
POST /api/pusher/auth endpoint | Correct — no changes needed |
| Channel naming convention | Correct — private-{role}-{id}, live-status |
| All event names | Correct — offer.received, negotiation.your_turn, etc. |
usePusherChannel / usePublicPusherChannel API | Correct — callers unchanged |
6. What changes
Change 1 — ESLint rule blocking static pusher-js imports
Prevents regression. Zero runtime effect.
Change 2 — pusherTriggerBatch for dual-notify mutations
Replaces sequential double-trigger calls in offers.ts and negotiations.ts.
Before (12 call sites):
pusherTrigger(env, `private-operator-${op_id}`, "offer.received", payload);
pusherTrigger(env, `private-streamer-${str_id}`, "offer.received", payload);After:
pusherTriggerBatch(env, [
{ channel: `private-operator-${op_id}`, event: "offer.received", data: payload },
{ channel: `private-streamer-${str_id}`, event: "offer.received", data: payload },
]);Pusher's /batch_events endpoint accepts up to 10 events per call — well within the dual-notify pattern.
7. Effort
| Task | Effort |
|---|---|
Add ESLint no-restricted-imports rule | 15 min |
Add pusherTriggerBatch to api/src/pusher.ts | 30 min |
Replace dual-trigger sites in offers.ts and negotiations.ts | 1 hour |
Total: ~2 hours. No page-level changes, no new dependencies.