Installatie

Realtime werkt zonder npm-package: je kopieert drie bestanden naar je Next.js-project en zet vier omgevingsvariabelen. Daarna gebruik je de useChannel-hook (browser) en de pusher-instantie (server).

1. Client-class

Kopieer naar lib/go-pusher-client.ts:

ts
type EventCallback = (data: any) => void;

export class GoPusherClient {
  private ws: WebSocket | null = null;
  private listeners = new Map<string, Map<string, Set<EventCallback>>>();
  private socketId: string | null = null;
  private pingInterval: ReturnType<typeof setInterval> | null = null;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private subscribedChannels = new Set<string>();
  private host: string;

  constructor(
    private clientKey: string,
    host?: string,
  ) {
    this.host = host ?? window.location.host;
  }

  /** Verbind met de realtime server */
  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      const proto = location.protocol === "https:" ? "wss" : "ws";
      this.ws = new WebSocket(`${proto}://${this.host}/ws/app/${this.clientKey}`);

      this.ws.onopen = () => {
        this.pingInterval = setInterval(() => {
          this.send("pusher:ping", {});
        }, 30000);
      };

      this.ws.onmessage = (e) => {
        const msg = JSON.parse(e.data);
        if (msg.event === "pusher:connection_established") {
          const d = typeof msg.data === "string" ? JSON.parse(msg.data) : msg.data;
          this.socketId = d.socket_id;
          resolve();
          return;
        }
        this.dispatch(msg.channel, msg.event, msg.data);
      };

      this.ws.onerror = () => reject(new Error("Verbinding mislukt"));
      this.ws.onclose = () => {
        this.cleanup();
        this.reconnectTimer = setTimeout(() => {
          this.connect().then(() => {
            this.subscribedChannels.forEach((ch) => this.subscribe(ch));
          });
        }, 3000);
      };
    });
  }

  /** Abonneer op een publiek kanaal */
  subscribe(channel: string): this {
    this.subscribedChannels.add(channel);
    this.send("pusher:subscribe", { channel });
    return this;
  }

  /** Abonneer op een privé- of presence-kanaal */
  async subscribePrivate(
    channel: string,
    authEndpoint = "/api/pusher/auth",
    channelData?: { user_id: string; user_info: Record<string, any> },
  ): Promise<this> {
    const body: Record<string, string> = {
      socket_id: this.socketId!,
      channel_name: channel,
    };
    if (channelData) body.channel_data = JSON.stringify(channelData);

    const res = await fetch(authEndpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    const { auth, channel_data } = await res.json();

    this.subscribedChannels.add(channel);
    this.send("pusher:subscribe", {
      channel,
      auth,
      channel_data: channel_data ?? body.channel_data,
    });
    return this;
  }

  /** Luister naar een event op een kanaal */
  on(channel: string, event: string, cb: EventCallback): this {
    if (!this.listeners.has(channel)) this.listeners.set(channel, new Map());
    const ch = this.listeners.get(channel)!;
    if (!ch.has(event)) ch.set(event, new Set());
    ch.get(event)!.add(cb);
    return this;
  }

  /** Stop met luisteren */
  off(channel: string, event: string, cb: EventCallback): void {
    this.listeners.get(channel)?.get(event)?.delete(cb);
  }

  /** Afmelden van een kanaal */
  unsubscribe(channel: string): void {
    this.subscribedChannels.delete(channel);
    this.listeners.delete(channel);
    this.send("pusher:unsubscribe", { channel });
  }

  /** Socket ID (nodig voor kanaal-auth) */
  getSocketId(): string | null {
    return this.socketId;
  }

  /** Verbreek de verbinding */
  disconnect(): void {
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    this.cleanup();
    this.ws?.close();
  }

  private send(event: string, data: any): void {
    this.ws?.send(JSON.stringify({ event, data }));
  }

  private dispatch(ch: string, ev: string, data: any): void {
    const parsed = typeof data === "string" ? JSON.parse(data) : data;
    this.listeners.get(ch)?.get(ev)?.forEach((cb) => cb(parsed));
  }

  private cleanup(): void {
    if (this.pingInterval) clearInterval(this.pingInterval);
    this.socketId = null;
  }
}

2. Server-class

Kopieer naar lib/go-pusher-server.ts:

ts
import { createHmac } from "crypto";

export class GoPusherServer {
  constructor(
    private apiKey: string,
    private baseUrl: string,
  ) {}

  /** Publiceer een event naar een kanaal */
  async publish(channel: string, event: string, data: unknown): Promise<void> {
    const res = await fetch(`${this.baseUrl}/api/events`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ channel, event, data: JSON.stringify(data) }),
    });
    if (!res.ok) throw new Error(`Realtime publish mislukt: ${res.status}`);
  }

  /** Onderteken een kanaal-autorisatie (voor privé/presence) */
  authenticate(
    socketId: string,
    channelName: string,
    channelData?: string,
  ): { auth: string; channel_data?: string } {
    let stringToSign = `${socketId}:${channelName}`;
    if (channelData) stringToSign += `:${channelData}`;

    const auth = createHmac("sha256", this.apiKey).update(stringToSign).digest("hex");
    return { auth, ...(channelData && { channel_data: channelData }) };
  }
}

// Eén gedeelde instantie, gevoed door je omgevingsvariabelen:
export const pusher = new GoPusherServer(
  process.env.GOPUSHER_API_KEY!,
  process.env.GOPUSHER_URL!,
);

3. React-hook

Kopieer naar hooks/use-channel.ts:

ts
"use client";
import { useEffect, useRef } from "react";
import { GoPusherClient } from "@/lib/go-pusher-client";

// Eén gedeelde verbinding per app
let client: GoPusherClient | null = null;
let connectPromise: Promise<void> | null = null;

function getClient(clientKey: string): Promise<GoPusherClient> {
  if (!client) {
    // De host komt uit NEXT_PUBLIC_GOPUSHER_HOST (bijv. realtime.eduinsights.nl)
    client = new GoPusherClient(clientKey, process.env.NEXT_PUBLIC_GOPUSHER_HOST);
    connectPromise = client.connect();
  }
  return connectPromise!.then(() => client!);
}

/**
 * Abonneer op een kanaal en luister naar een event.
 *
 *   useChannel(process.env.NEXT_PUBLIC_GOPUSHER_KEY!, "chat", "nieuw-bericht", (data) => {
 *     setBericht(data);
 *   });
 */
export function useChannel<T = any>(
  clientKey: string,
  channel: string,
  event: string,
  callback: (data: T) => void,
): void {
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  useEffect(() => {
    let cancelled = false;
    const handler = (data: T) => {
      if (!cancelled) callbackRef.current(data);
    };
    getClient(clientKey).then((c) => {
      if (cancelled) return;
      c.subscribe(channel).on(channel, event, handler);
    });
    return () => {
      cancelled = true;
      client?.off(channel, event, handler);
    };
  }, [clientKey, channel, event]);
}

4. Omgevingsvariabelen

Voeg toe aan .env.local (de waarden vind je op het tabblad Gegevens van je realtime app). Heb je de realtime app aan je Cloud-app gekoppeld? Dan staan deze er al automatisch in.

bash
# Server (Server Actions / Route Handlers)
GOPUSHER_API_KEY=JE_API_KEY
GOPUSHER_URL=https://realtime.eduinsights.nl

# Browser (client components)
NEXT_PUBLIC_GOPUSHER_HOST=realtime.eduinsights.nl
NEXT_PUBLIC_GOPUSHER_KEY=JE_CLIENT_KEY
Houd je API-sleutel geheim
GOPUSHER_API_KEY geeft volledige publiceer-toegang en wordt gebruikt om kanalen te ondertekenen. Gebruik hem alleen server-side, nooit met een NEXT_PUBLIC_-prefix.

Klaar? Ga verder met Client (browser).