Skip to content

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:

SideWhat 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:

ts
import Pusher from "pusher-js";  // ← pulled into SSR bundle, crashes CF Workers

This 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.

ts
// 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:

ts
// 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):

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

ChannelHookUsed in
live-status (public)usePublicPusherChannelStreamersList.tsx
private-streamer-{id}usePusherChannelProfileLayout.tsx
private-operator-{id}usePusherChannelOperatorLayout.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 Channels

No changes needed here — this is already working correctly.


5. What does NOT change

ConcernStatus
api/src/pusher.ts (trigger + sign)Correct — no changes needed
POST /api/pusher/auth endpointCorrect — no changes needed
Channel naming conventionCorrect — private-{role}-{id}, live-status
All event namesCorrect — offer.received, negotiation.your_turn, etc.
usePusherChannel / usePublicPusherChannel APICorrect — 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):

ts
pusherTrigger(env, `private-operator-${op_id}`, "offer.received", payload);
pusherTrigger(env, `private-streamer-${str_id}`, "offer.received", payload);

After:

ts
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

TaskEffort
Add ESLint no-restricted-imports rule15 min
Add pusherTriggerBatch to api/src/pusher.ts30 min
Replace dual-trigger sites in offers.ts and negotiations.ts1 hour

Total: ~2 hours. No page-level changes, no new dependencies.

Verifluence Documentation