Source

lib/events/Relay.ts

import { Listener } from "./Listener";
import { Dispatcher } from "./Dispatcher";
import { SessionClient } from "../client/SessionClient";
import { SessionState } from "./SessionState";
import { WindowActivityManager } from "./WindowActivityManager";
import { Scheduler, SessionCheckResult } from "./Scheduler";
import { SessionChannel, BroadcastMessage } from "./SessionChannel";
import { InternalOptions } from "../../Hanko";

/**
 * A class that manages session checks, dispatches events based on session status,
 * and uses broadcast channels for inter-tab communication.
 *
 * @category SDK
 * @subcategory Internal
 * @extends Dispatcher
 * @param {string} api - The API endpoint URL.
 * @param {InternalOptions} options - The internal configuration options of the SDK.
 */
export class Relay extends Dispatcher {
  listener = new Listener(); // Listener for session-related events.
  private readonly checkInterval: number = 30000; // Interval for session validity checks in milliseconds.
  private readonly client: SessionClient; // Client for session validation.
  private readonly sessionState: SessionState; // Manages session-related states.
  private readonly windowActivityManager: WindowActivityManager; // Manages window activity states.
  private readonly scheduler: Scheduler; //  Schedules session validity checks.
  private readonly sessionChannel: SessionChannel; // Handles inter-tab communication via broadcast channels.
  private isLoggedIn: boolean;

  // eslint-disable-next-line require-jsdoc
  constructor(api: string, options: InternalOptions) {
    super();
    this.client = new SessionClient(api, options);
    this.checkInterval = options.sessionCheckInterval;
    this.sessionState = new SessionState(`${options.cookieName}_session_state`);
    this.sessionChannel = new SessionChannel(
      options.sessionCheckChannelName,
      () => this.onChannelSessionExpired(),
      (msg) => this.onChannelSessionCreated(msg),
      () => this.onChannelLeadershipRequested(),
    );
    this.scheduler = new Scheduler(
      this.checkInterval,
      () => this.checkSession(),
      () => this.onSessionExpired(),
    );
    this.windowActivityManager = new WindowActivityManager(
      () => this.startSessionCheck(),
      () => this.scheduler.stop(),
    );

    const now = Date.now();
    const { expiration } = this.sessionState.load();

    this.isLoggedIn = now < expiration;
    this.initializeEventListeners();
    this.startSessionCheck();
  }

  /**
   * Sets up all event listeners and initializes session management.
   * This method is crucial for ensuring the session is monitored across all tabs.
   * @private
   */
  private initializeEventListeners(): void {
    // Listen for session creation events
    this.listener.onSessionCreated((detail) => {
      const { claims } = detail;
      const expiration = Date.parse(claims.expiration);
      const lastCheck = Date.now();

      this.isLoggedIn = true;
      this.sessionState.save({ expiration, lastCheck }); // Save initial session state
      this.sessionChannel.post({ action: "sessionCreated", claims }); // Inform other tabs
      this.startSessionCheck(); // Begin session checks now that a user is logged in
    });

    // Listen for user logout events
    this.listener.onUserLoggedOut(() => {
      this.isLoggedIn = false;
      this.sessionChannel.post({ action: "sessionExpired" }); // Inform other tabs session ended
      this.sessionState.save(null);
      this.scheduler.stop();
    });

    window.addEventListener("beforeunload", () => this.scheduler.stop());
  }

  /**
   * Initiates session checking based on the last check time.
   * This method decides when the next check should occur to balance between performance and freshness.
   * @private
   */
  private startSessionCheck(): void {
    if (this.windowActivityManager.hasFocus()) {
      this.sessionChannel.post({ action: "requestLeadership" }); // Inform other tabs this tab is now checking
    } else {
      return;
    }

    if (this.scheduler.isRunning()) {
      return;
    }

    const { lastCheck, expiration } = this.sessionState.load();

    if (this.isLoggedIn) {
      this.scheduler.start(lastCheck, expiration);
    }
  }

  /**
   * Validates the current session and updates session information.
   * This method checks if the session is still valid and updates local data accordingly.
   * @returns {Promise<SessionCheckResult>} - A promise that resolves with the session check result.
   * @private
   */
  private async checkSession(): Promise<SessionCheckResult> {
    const lastCheck = Date.now();
    // eslint-disable-next-line camelcase
    const { is_valid, claims, expiration_time } = await this.client.validate();

    // eslint-disable-next-line camelcase
    const expiration = expiration_time ? Date.parse(expiration_time) : 0;

    // eslint-disable-next-line camelcase
    if (!is_valid && this.isLoggedIn) {
      this.dispatchSessionExpiredEvent();
    }

    // eslint-disable-next-line camelcase
    if (is_valid) {
      this.isLoggedIn = true;
      this.sessionState.save({ lastCheck, expiration });
    } else {
      this.isLoggedIn = false;
      this.sessionState.save(null);
      this.sessionChannel.post({ action: "sessionExpired" }); // Inform other tabs
    }

    return {
      // eslint-disable-next-line camelcase
      is_valid,
      claims,
      expiration,
    };
  }

  /**
   * Resets session-related states when a session expires.
   * Ensures that authentication state is cleared and an expiration event is dispatched.
   * Assumes the user is logged out by default if the session state is unknown.
   * @private
   */
  private onSessionExpired() {
    if (this.isLoggedIn) {
      this.isLoggedIn = false;
      this.sessionState.save(null);
      this.sessionChannel.post({ action: "sessionExpired" }); // Inform other tabs
      this.dispatchSessionExpiredEvent();
    }
  }

  /**
   * Handles session expired events from broadcast messages.
   * @private
   */
  private onChannelSessionExpired() {
    if (this.isLoggedIn) {
      this.isLoggedIn = false;
      this.dispatchSessionExpiredEvent();
    }
  }

  /**
   * Handles session creation events from broadcast messages.
   * @param {BroadcastMessage} msg - The broadcast message containing session details.
   * @private
   */
  private onChannelSessionCreated(msg: BroadcastMessage) {
    const { claims } = msg;
    const now = Date.now();
    const expiration = Date.parse(claims.expiration);
    const expirationSeconds = expiration - now;

    this.isLoggedIn = true;
    this.dispatchSessionCreatedEvent({
      claims,
      expirationSeconds, // deprecated
    });
  }

  /**
   * Handles leadership requests from other tabs.
   * @private
   */
  private onChannelLeadershipRequested() {
    if (!this.windowActivityManager.hasFocus()) {
      this.scheduler.stop();
    }
  }
}