Wednesday, July 15, 2026 2:12:23 AM
> client.rb
# frozen_string_literal: true

module ESM
  module Arma
    class Client
      include Lifecycle

      HEARTBEAT_INTERVAL = 3 # seconds

      Metadata = Data.define(:vg_enabled, :vg_max_sizes)

      attr_reader :public_id, :server_id, :session_id, :connected_at

      delegate :address, to: :@socket

      def initialize(tcp_client)
        @socket = ClientSocket.new(tcp_client)
        @ledger = Ledger.new
        @config = ESM.config.connection_client

        @public_id = nil
        @server_id = nil
        @session_id = nil

        @thread_pool = Concurrent::CachedThreadPool.new
        set_metadata(vg_enabled: false, vg_max_sizes: 0)

        execution_interval = @config.request_check
        @task = TimerTask.execute(execution_interval:) { on_message }

        @connected_at = Time.current
        @last_heartbeat = Time.current

        info!(address:, state: :on_connect)
      end

      def set_metadata(**)
        @metadata = Metadata.new(**)
      end

      def close(reason = "")
        info!(
          address:,
          public_id:,
          server_id:,
          state: :closing,
          reason: reason.presence
        )

        @socket.shutdown
        @socket.close

        ESM::Database.with_connection do
          on_disconnect(reason)
        end

        ESM::Arma::Server.on_disconnect(self)

        @task.shutdown
        @thread_pool&.shutdown
        @thread_pool&.wait_for_termination(0.1)
      end

      def send_message(message, **)
        send_request(message, type: :message, **)
      end

      def send_error(content, block: false)
        message = ESM::Message.new.add_error(:message, content)
        send_request(message, type: :error, block:)
      end

      #
      # Sends a request over the network to the client
      #
      # @param message [ESM::Message, nil] The data to send
      # @param type [Symbol] The type of request. See ESM::Arma::Request::TYPES
      # @param block [true/false] Cause this method to block the current thread and either
      #   1. Until the request is responded to by the client
      #   2. The timeout is reached. This will raise ESM::Exception::RejectedPromise
      # @param timeout [Integer] A number in seconds on how long the process will block before
      #   considering a message to be timed out. Defaults to response_timeout in config.yml
      #
      # @return [ESM::Arma::Promise, ESM::Message]
      #   If block is false, a promise in an processing status is returned
      #   If block is true, the response as ESM::Message is returned
      #
      # @raises ESM::Exception::RejectedPromise, ESM::Exception::ExtensionError
      #
      def send_request(message = nil, type:, block: true, timeout: @config.response_timeout)
        # I feel so dirty. Multiline unless statements *shudder*
        unless message.nil? || message.is_a?(ESM::Message)
          raise TypeError, "Expected ESM::Message or nil. Got #{message.class}"
        end

        info!(
          address:,
          public_id:,
          server_id:,
          outbound: {type:, content: message&.to_h}
        )

        id = message&.id
        content = message&.to_s

        # Send the data over the network
        promise = write(id:, type:, content:)
        return promise unless block

        # Block and wait for a response or timeout
        response = promise.wait_for_response(timeout)
        raise response.reason if response.rejected?

        response_message = ESM::Message.from_string(response.value)
        response_message.set_metadata(server_id:)

        info!(address:, public_id:, server_id:, inbound: response_message.to_h)

        # Messages with errors do not contain any extra data or metadata
        # Merge the errors from the response into the original message and use that
        # to build the error messages (the error message can reference data/metadata)
        if response_message.errors?
          message
            .set_metadata(server_id:)
            .add_errors(response_message.errors.map(&:to_h))

          raise ESM::Exception::ExtensionError.new(message.error_messages)
        end

        response_message
      end

      #
      # Lower level method to send a request to the client and either disregard the response or block (default)
      #
      # @see #send_request or #send_message for higher level methods
      #
      # @param id [String, nil] A UUID, if any, that will be used to differentiate this request
      # @param type [Symbol] The request type.
      #     See ESM::Arma::Client::Request::TYPES for full list
      # @param content [Symbol, Array<Numeric>, nil, #bytes] The content to send in the request
      # @param block [true, false] Should this method block and wait for the response?
      #
      # @return [ESM::Arma::Client::Promise]
      #
      def write(type:, id: nil, content: nil)
        request = Request.new(id:, type:, content:)

        # Adding the request to the ledger allows us to track the request across multiple threads
        # ensuring the response to passed back to the blocking thread
        promise = @ledger.add(request)

        # All data passed is in JSON format
        # ESM will never be huge to the point where JSON is a limitation so this
        # isn't a concern of mine
        content = request.to_json

        # Compress
        content = ActiveSupport::Gzip.compress(content)

        # Encrypt with the current key. The handshake relies on this happening
        # before the caller swaps @encryption (see Lifecycle#authenticate!).
        content = @encryption.encrypt(content)

        @socket.write(content)

        promise
      end

      def update_last_heartbeat
        @last_heartbeat = Time.current
      end

      def recent_heartbeat?
        (Time.current - @last_heartbeat) < HEARTBEAT_INTERVAL.seconds
      end

      private

      def read
        data = @socket.read
        return if data.blank?

        # The first data we receive should be the identification (when @id is nil)
        # Every request from that point on will be encrypted
        data =
          if @public_id.nil?
            # Since this is the only non-encrypted data, we're going to take the exact
            # size of the base64 encoded identification request, which is 176 bytes
            data[..175]
          else
            @encryption.decrypt(data)
          end

        # Decompress
        data = ActiveSupport::Gzip.decompress(data)

        data = ESM::JSON.parse(data)
        return if data.blank?

        Request.from_client(data)
      end
    end
  end
end
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 43aa0d7