You are here

nodejs.module in Node.js integration 6

Same filename and directory in other branches
  1. 8 nodejs.module
  2. 7 nodejs.module

File

nodejs.module
View source
<?php

/**
 * Generate a token for a piece of content.
 */
function nodejs_generate_content_token() {
  return _drupal_hmac_base64(_drupal_random_bytes(512), drupal_get_private_key() . _drupal_get_hash_salt());
}

/**
 * Send a content change message to a content channel.
 */
function nodejs_send_content_channel_message($message) {
  Nodejs::sendContentTokenMessage($message);
}

/**
 * Send a content channel token to Node.js.
 *
 * @param mixed $channel
 * @param mixed $anonymous_only
 */
function nodejs_send_content_channel_token($channel, $anonymous_only = FALSE) {
  $message = (object) array(
    'token' => nodejs_generate_content_token(),
    'anonymousOnly' => $anonymous_only,
    'channel' => $channel,
  );
  $response = Nodejs::sendContentToken($message);
  if (isset($response->error)) {
    $args = array(
      '%token' => $message->token,
      '%code' => $response->code,
      '%error' => $response->error,
    );
    watchdog('nodejs', t('Error sending content token "%token" to the Node.js server: [%code] %error', $args));
    return FALSE;
  }

  // We always set this in drupal.settings, even though Ajax requests will not
  // see it. It's a bit ugly, but it means that setting the tokens for full
  // page requests will just work.
  drupal_add_js(array(
    'nodejs' => array(
      'contentTokens' => array(
        $channel => $message->token,
      ),
    ),
  ), array(
    'type' => 'setting',
  ));

  // We return the message, so that calling code can use it for Ajax requests.
  return $message->token;
}

/**
 * Kick a user off the node.js server.
 *
 * @param mixed $uid
 */
function nodejs_kick_user($uid) {
  $response = Nodejs::kickUser($uid);
  if (isset($response->error)) {
    watchdog('nodejs', t('Error kicking uid "%uid" from the Node.js server: [%code] %error', array(
      '%uid' => $uid,
      '%code' => $response->code,
      '%error' => $response->error,
    )));
    return FALSE;
  }
  else {
    return $response;
  }
}

/**
 * Logout any sockets associated with the given token from the node.js server.
 *
 * @param mixed $token
 */
function nodejs_logout_user($token) {
  $response = Nodejs::logoutUser($token);
  if (isset($response->error)) {
    watchdog('nodejs', t('Error logging out token "%token" from the Node.js server: [%code] %error', array(
      '%token' => $token,
      '%code' => $response->code,
      '%error' => $response->error,
    )));
    return FALSE;
  }
  else {
    return $response;
  }
}

/**
 * Set the list of uids a user can see presence notifications for.
 *
 * @param $uid
 * @param $uids
 */
function nodejs_set_user_presence_list($uid, array $uids) {
  $response = Nodejs::setUserPresenceList($uid, $uids);
  if (isset($response->error)) {
    watchdog('nodejs', t('Error setting user presence list for uid "%uid", error from the Node.js server: [%code] %error', array(
      '%uid' => $uid,
      '%code' => $response->code,
      '%error' => $response->error,
    )));
    return FALSE;
  }
  else {
    return $response;
  }
}

/**
 * Broadcast a message to all clients.
 *
 * @param string $subject
 * @param string $body
 */
function nodejs_broadcast_message($subject, $body) {
  $message = (object) array(
    'broadcast' => TRUE,
    'data' => (object) array(
      'subject' => $subject,
      'body' => $body,
    ),
    'channel' => 'nodejs_notify',
  );
  nodejs_enqueue_message($message);
}

/**
 * Send a message to all users subscribed to a given channel.
 */
function nodejs_send_channel_message($channel, $subject, $body) {
  $message = (object) array(
    'broadcast' => FALSE,
    'data' => (object) array(
      'subject' => $subject,
      'body' => $body,
    ),
    'channel' => $channel,
  );
  nodejs_enqueue_message($message);
}

/**
 * Send a message to given user.
 *
 * @param int $uid
 * @param string $subject
 * @param string $body
 */
function nodejs_send_user_message($uid, $subject, $body) {
  $message = (object) array(
    'broadcast' => FALSE,
    'data' => (object) array(
      'subject' => $subject,
      'body' => $body,
    ),
    'channel' => 'nodejs_user_' . $uid,
    'callback' => 'nodejsNotify',
  );
  nodejs_enqueue_message($message);
}

/**
 * Send a message to multiple users.
 *
 * @param string|array $uids
 *   A list of uid seperated by comma (,) or an array of uids
 * @param string $subject
 * @param string $body
 */
function nodejs_send_user_message_multiple($uids, $subject, $body) {
  if (!is_array($uids)) {
    $uids = explode(',', $uids);
  }
  foreach ($uids as $uid) {
    nodejs_send_user_message($uid, $subject, $body);
  }
}

/**
 * Send a message to users in a role.
 *
 * @param string $role_name
 * @param string $subject
 * @param string $body
 */
function nodejs_send_role_message($role_name, $subject, $body) {
  $data = db_query("SELECT u.uid FROM {users} as u LEFT JOIN {users_roles} as ur on ur.uid = u.uid LEFT JOIN {role} as r on ur.rid = r.rid");
  while ($row = db_fetch_object($data)) {
    $uids[] = $row->uid;
  }
  nodejs_send_user_message_multiple($uids, $subject, $body);
}

/**
 * Implements hook_init().
 */
function nodejs_init() {
  register_shutdown_function(array(
    'nodejs',
    'sendMessages',
  ));
  $_SESSION['nodejs_config'] = $nodejs_config = nodejs_get_config();
  if (isset($nodejs_config['serviceKey'])) {
    unset($nodejs_config['serviceKey']);
  }
  $socket_io_config = nodejs_get_socketio_js_config($nodejs_config);
  $hackity_hack = <<<JS
//--><!]]>
</script>
<script type="text/javascript" src="{<span class="php-variable">$socket_io_config</span>[<span class="php-string">'path'</span>]}"></script>
<script type="text/javascript">
<!--//--><![CDATA[//><!--]]
JS;
  drupal_add_js($hackity_hack, 'inline', 'header');
  drupal_add_js(drupal_get_path('module', 'nodejs') . '/nodejs.js', 'module', 'footer');
  if (isset($nodejs_config)) {
    foreach ($nodejs_config as $key => $value) {
      drupal_add_js(array(
        'nodejs' => array(
          $key => $value,
        ),
      ), 'setting');
    }
  }
  foreach (nodejs_get_js_handlers() as $handler_file) {
    drupal_add_js($handler_file, 'file', 'footer', FALSE, TRUE);
  }
}

/**
 * Return the path to the socket.io client js.
 */
function nodejs_get_socketio_js_config($nodejs_config) {
  $socket_io_config = array(
    'path' => variable_get('nodejs_socket_io_path', FALSE),
    'type' => variable_get('nodejs_socket_io_type', 'external'),
  );
  if (!$socket_io_config['path']) {
    $socket_io_config['path'] = $nodejs_config['scheme'] . '://' . $nodejs_config['host'] . ':' . $nodejs_config['port'] . $nodejs_config['resource'] . '/socket.io.js';
  }
  return $socket_io_config;
}

/**
 * Get a list of javascript handler files.
 */
function nodejs_get_js_handlers() {
  $handlers = module_invoke_all('nodejs_handlers_info');
  drupal_alter('nodejs_js_handlers', $handlers);
  return $handlers;
}

/**
 * Implements hook_menu().
 */
function nodejs_menu() {
  return array(
    'admin/settings/nodejs' => array(
      'title' => 'Nodejs',
      'description' => t('Configure nodejs module.'),
      'position' => 'left',
      'page callback' => 'system_admin_menu_block_page',
      'access arguments' => array(
        'access administration pages',
      ),
      'file' => 'system.admin.inc',
      'file path' => drupal_get_path('module', 'system'),
    ),
    'admin/settings/nodejs/config' => array(
      'title' => t('Configuration'),
      'description' => t('Adjust node.js settings.'),
      'page callback' => 'drupal_get_form',
      'page arguments' => array(
        'nodejs_settings',
      ),
      'access arguments' => array(
        'administer site configuration',
      ),
      'file' => 'nodejs.admin.inc',
    ),
    'nodejs/message' => array(
      'title' => t('Message from Node.js server'),
      'page callback' => 'nodejs_message_handler',
      'access callback' => TRUE,
      'type' => MENU_CALLBACK,
    ),
    'nodejs/user/channel/add' => array(
      'title' => t('Add a channel to the Node.js server'),
      'page callback' => 'drupal_get_form',
      'page arguments' => array(
        'nodejs_add_user_to_channel_form',
      ),
      'access callback' => TRUE,
      'type' => MENU_CALLBACK,
    ),
  );
}

/**
 * Get a list of active channels from the node.js server.
 *
 * @access public
 * @return array
 */
function nodejs_get_channels() {
  $response = Nodejs::getChannels();
  if (isset($response->error)) {
    watchdog('nodejs', t('Error getting channel list from Node.js server: [%code] %error', array(
      '%code' => $response->code,
      '%error' => $response->error,
    )));
    return array();
  }
  else {
    $channels = json_decode($response->data);
    return (array) $channels;
  }
}

/**
 * Form callback, add a user to a channel.
 *
 * @param mixed $form
 * @param mixed $form_state
 * @return array
 */
function nodejs_add_user_to_channel_form() {
  $form = array();
  $form['nodejs_uid'] = array(
    '#type' => 'textfield',
    '#description' => t('The user uid to add to a channel.'),
    '#title' => t('User uid to add'),
  );
  $form['nodejs_channel'] = array(
    '#type' => 'textfield',
    '#description' => t('The name of the channel to give a user access to.'),
    '#title' => t('Channel to add'),
  );
  $form['nodejs_submit'] = array(
    '#type' => 'submit',
    '#value' => t('Add user'),
  );
  return $form;
}

/**
 * Form submit callback - add a user to a channel on the Node.js server.
 *
 * @param mixed $form
 * @param mixed $form_state
 */
function nodejs_add_user_to_channel_form_submit($form, &$form_state) {
  $values = (object) $form_state['values'];
  if (nodejs_add_user_to_channel($values->nodejs_uid, $values->nodejs_channel)) {
    drupal_set_message(t("Added uid %uid to %channel.", array(
      '%uid' => $values->nodejs_uid,
      '%channel' => $values->nodejs_channel,
    )));
  }
  else {
    drupal_set_message(t("Failed to add uid %uid to %channel.", array(
      '%uid' => ${$values}->nodejs_uid,
      '%channel' => $values->nodejs_channel,
    )), 'error');
  }
}

/**
 * Form validate callback - add a user to a channel on the Node.js server.
 *
 * @param mixed $form
 * @param mixed $form_state
 * @return void
 */
function nodejs_add_user_to_channel_form_validate($form, &$form_state) {
  $values = (object) $form_state['values'];
  if (!preg_match('/^\\d+$/', $values->nodejs_uid)) {
    form_set_error('nodejs_uid', t('Invalid uid - please enter a numeric uid.'));
  }
  if (!preg_match('/^([a-z0-9_]+)$/i', $values->nodejs_channel)) {
    form_set_error('nodejs_channel', t('Invalid channel name - only numbers, letters and underscores are allowed.'));
  }
}

/**
 * Enqueue a message for sending at the end of the request.
 *
 * @param StdClass $message
 */
function nodejs_enqueue_message(StdClass $message) {
  Nodejs::enqueueMessage($message);
}

/**
 * Send a message immediately.
 *
 * @param StdClass $message
 */
function nodejs_send_message(StdClass $message) {
  return Nodejs::sendMessage($message);
}

/**
 * Implements hook_nodejs_user_channels().
 */
function nodejs_nodejs_user_channels($account) {
  if (variable_get('nodejs_enable_userchannel', TRUE) && $account->uid) {
    return array(
      'nodejs_user_' . $account->uid,
    );
  }
  return array();
}

/**
 * Implements hook_user_logout().
 */
function nodejs_user_logout($account) {
  nodejs_logout_user($_SESSION['nodejs_config']['authToken']);
}

/**
 * Check if the given service key is valid.
 */
function nodejs_is_valid_service_key($service_key) {
  return $service_key == variable_get('nodejs_config_serviceKey', '');
}

/**
 * Menu callback: handles all messages from Node.js server.
 */
function nodejs_message_handler() {
  if (!isset($_POST['serviceKey']) || !nodejs_is_valid_service_key($_POST['serviceKey'])) {
    drupal_json(array(
      'error' => t('Invalid service key.'),
    ));
    exit;
  }
  if (!isset($_POST['messageJson'])) {
    drupal_json(array(
      'error' => t('No message.'),
    ));
    exit;
  }
  $message = json_decode($_POST['messageJson'], TRUE);
  $response = array();
  switch ($message['messageType']) {
    case 'authenticate':
      $response = nodejs_auth_check($message);
      break;
    case 'userOffline':
      nodejs_user_set_offline($message['uid']);
      break;
    default:
      $handlers = array();
      foreach (module_implements('nodejs_message_callback') as $module) {
        $function = $module . '_nodejs_message_callback';
        $handlers += $function($message['messageType']);
      }
      foreach ($handlers as $callback) {
        $callback($message, $response);
      }
  }
  drupal_alter('nodejs_message_response', $response, $message);
  drupal_json($response ? $response : array(
    'error' => t('Not implemented'),
  ));
  exit;
}

/**
 * Checks the given key to see if it matches a valid session.
 */
function nodejs_auth_check($message) {
  $uid = db_result(db_query("SELECT uid FROM {sessions} WHERE MD5(sid) = '%s'", $message['authToken']));
  $auth_user = $uid > 0 ? user_load($uid) : drupal_anonymous_user();
  $auth_user->authToken = $message['authToken'];
  $auth_user->nodejsValidAuthToken = $uid !== FALSE;
  $auth_user->clientId = $message['clientId'];
  if ($auth_user->nodejsValidAuthToken) {

    // Get the list of channels I have access to.
    $auth_user->channels = array();
    foreach (module_implements('nodejs_user_channels') as $module) {
      $function = $module . '_nodejs_user_channels';
      foreach ($function($auth_user) as $channel) {
        $auth_user->channels[] = $channel;
      }
    }

    // Get the list of users who can see presence notifications about me.
    $auth_user->presenceUids = array_unique(module_invoke_all('nodejs_user_presence_list', $auth_user));
    $nodejs_config = nodejs_get_config();
    $auth_user->serviceKey = $nodejs_config['serviceKey'];
    drupal_set_header('NodejsServiceKey: ' . $nodejs_config['serviceKey']);
    drupal_alter('nodejs_auth_user', $auth_user);
    if ($auth_user->uid) {
      nodejs_user_set_online($auth_user->uid);
    }
    $auth_user->contentTokens = isset($message['contentTokens']) ? $message['contentTokens'] : array();
  }
  return $auth_user;
}

/**
 * Set the user as online.
 *
 * @param $uid
 */
function nodejs_user_set_online($uid) {
  db_query('INSERT INTO {nodejs_presence} (uid, login_time) VALUES (%d, %d)', $uid, time());
}

/**
 * Set the user as offline.
 *
 * @param $uid
 */
function nodejs_user_set_offline($uid) {
  db_query('DELETE FROM {nodejs_presence} WHERE uid = %d', $uid);
}

/**
 * Get nodejs server config.
 *
 * @return array
 */
function nodejs_get_config() {
  $defaults = array(
    'scheme' => variable_get('nodejs_server_scheme', 'http'),
    'secure' => variable_get('nodejs_server_scheme', 'http') == 'https' ? 1 : 0,
    'host' => variable_get('nodejs_config_host', 'localhost'),
    'port' => variable_get('nodejs_config_port', '8080'),
    'resource' => variable_get('nodejs_config_resource', '/socket.io'),
    'authToken' => md5(session_id()),
    'serviceKey' => variable_get('nodejs_config_serviceKey', ''),
  );
  return variable_get('nodejs_config', array()) + $defaults;
}

/**
 * Get the URL of a Node.js callback.
 *
 * @param array $config
 *   The result of nodejs_get_config().
 * @param string $callback
 *   The path to call on Node.js server (without leading /).
 * @return string
 */
function nodejs_get_url($config, $callback = '') {
  return $config['scheme'] . '://' . $config['host'] . ':' . $config['port'] . '/' . $callback;
}

/**
 * Remove a user from a channel.
 *
 * @param mixed $uid
 * @param mixed $channel
 * @return boolean
 */
function nodejs_remove_user_from_channel($uid, $channel) {
  $result = Nodejs::removeUserFromChannel($uid, $channel);
  if (isset($result->error)) {
    $params = array(
      '%uid' => $uid,
      '%channel' => $channel,
      '%code' => $result->code,
      '%error' => $result->error,
    );
    watchdog('nodejs', t('Error removing user %uid from channel %channel on Node.js server: [%code] %error', $params));
    return (object) array();
  }
  else {
    return TRUE;
  }
}

/**
 * Add a user to a channel.
 *
 * @param mixed $uid
 * @param mixed $channel
 * @return boolean
 */
function nodejs_add_user_to_channel($uid, $channel) {
  $result = Nodejs::addUserToChannel($uid, $channel);
  if (isset($result->error)) {
    $params = array(
      '%uid' => $uid,
      '%channel' => $channel,
      '%code' => $result->code,
      '%error' => $result->error,
    );
    watchdog('nodejs', t('Error adding user %uid to channel %channel on Node.js server: [%code] %error', $params));
    return (object) array();
  }
  else {
    return TRUE;
  }
}

/**
 * Get the client socket id associated with this request.
 */
function nodejs_get_client_socket_id() {
  $client_socket_id = isset($_POST['nodejs_client_socket_id']) ? $_POST['nodejs_client_socket_id'] : '';
  return preg_match('/^\\d+$/', $client_socket_id) ? $client_socket_id : '';
}
class Nodejs {
  public static $messages = array();
  public static $config = NULL;
  public static $baseUrl = NULL;
  public static $headers = NULL;
  public static function initConfig() {
    if (!isset(self::$config)) {
      self::$config = nodejs_get_config();
      self::$headers = array(
        'NodejsServiceKey' => self::$config['serviceKey'],
      );
      self::$baseUrl = nodejs_get_url(self::$config);
    }
  }
  public static function getMessages() {
    return self::$messages;
  }
  public static function enqueueMessage(StdClass $message) {
    self::$messages[] = $message;
  }
  public static function sendMessages() {
    foreach (self::$messages as $message) {
      self::sendMessage($message);
    }
  }
  public static function sendMessage(StdClass $message) {
    self::initConfig();
    drupal_alter('nodejs_message', $message);
    $message->clientSocketId = str_replace("/", "", nodejs_get_client_socket_id());
    $request_method = 'POST';
    $request_retry = 3;
    $data = json_encode($message);
    return drupal_http_request(self::$baseUrl . 'nodejs/publish', self::$headers, $request_method, $data, $request_retry);
  }
  public static function setUserPresenceList($uid, array $uids) {
    self::initConfig();
    return drupal_http_request(self::$baseUrl . "nodejs/user/presence-list/{$uid}/" . implode(',', $uids), self::$headers);
  }
  public static function logoutUser($token) {
    self::initConfig();
    return drupal_http_request(self::$baseUrl . "nodejs/user/logout/{$token}", self::$headers);
  }
  public static function sendContentTokenMessage($message) {
    self::initConfig();
    $message->clientSocketId = nodejs_get_client_socket_id();
    $request_method = 'POST';
    $request_retry = 3;
    $data = json_encode($message);
    return drupal_http_request(self::$baseUrl . 'nodejs/content/token/message', self::$headers, $request_method, $data, $request_retry);
  }
  public static function sendContentToken($message) {
    self::initConfig();
    $request_method = 'POST';
    $request_retry = 3;
    $data = json_encode($message);
    return drupal_http_request(self::$baseUrl . 'nodejs/content/token', self::$headers, $request_method, $data, $request_retry);
  }
  public static function kickUser($uid) {
    self::initConfig();
    return drupal_http_request(self::$baseUrl . "nodejs/user/kick/{$uid}", self::$headers);
  }
  public static function addUserToChannel($uid, $channel) {
    self::initConfig();
    return drupal_http_request(self::$baseUrl . "nodejs/user/channel/add/{$channel}/{$uid}", self::$headers);
  }
  public static function removeUserFromChannel($uid, $channel) {
    self::initConfig();
    return drupal_http_request(self::$baseUrl . "nodejs/user/channel/remove/{$channel}/{$uid}", self::$headers);
  }

}
function _drupal_hmac_base64($data, $key) {
  $hmac = base64_encode(hash_hmac('sha256', $data, $key, TRUE));

  // Modify the hmac so it's safe to use in URLs.
  return strtr($hmac, array(
    '+' => '-',
    '/' => '_',
    '=' => '',
  ));
}
function _drupal_random_bytes($count) {

  // $random_state does not use drupal_static as it stores random bytes.
  static $random_state, $bytes;

  // Initialize on the first call. The contents of $_SERVER includes a mix of
  // user-specific and system information that varies a little with each page.
  if (!isset($random_state)) {
    $random_state = print_r($_SERVER, TRUE);
    if (function_exists('getmypid')) {

      // Further initialize with the somewhat random PHP process ID.
      $random_state .= getmypid();
    }
    $bytes = '';
  }
  if (strlen($bytes) < $count) {

    // /dev/urandom is available on many *nix systems and is considered the
    // best commonly available pseudo-random source.
    if ($fh = @fopen('/dev/urandom', 'rb')) {

      // PHP only performs buffered reads, so in reality it will always read
      // at least 4096 bytes. Thus, it costs nothing extra to read and store
      // that much so as to speed any additional invocations.
      $bytes .= fread($fh, max(4096, $count));
      fclose($fh);
    }

    // If /dev/urandom is not available or returns no bytes, this loop will
    // generate a good set of pseudo-random bytes on any system.
    // Note that it may be important that our $random_state is passed
    // through hash() prior to being rolled into $output, that the two hash()
    // invocations are different, and that the extra input into the first one -
    // the microtime() - is prepended rather than appended. This is to avoid
    // directly leaking $random_state via the $output stream, which could
    // allow for trivial prediction of further "random" numbers.
    while (strlen($bytes) < $count) {
      $random_state = hash('sha256', microtime() . mt_rand() . $random_state);
      $bytes .= hash('sha256', mt_rand() . $random_state, TRUE);
    }
  }
  $output = substr($bytes, 0, $count);
  $bytes = substr($bytes, $count);
  return $output;
}
function _drupal_get_hash_salt() {
  global $drupal_hash_salt, $db_url;

  // If the $drupal_hash_salt variable is empty, a hash of the serialized
  // database credentials is used as a fallback salt.
  return empty($drupal_hash_salt) ? hash('sha256', serialize($db_url)) : $drupal_hash_salt;
}

Functions

Namesort descending Description
nodejs_add_user_to_channel Add a user to a channel.
nodejs_add_user_to_channel_form Form callback, add a user to a channel.
nodejs_add_user_to_channel_form_submit Form submit callback - add a user to a channel on the Node.js server.
nodejs_add_user_to_channel_form_validate Form validate callback - add a user to a channel on the Node.js server.
nodejs_auth_check Checks the given key to see if it matches a valid session.
nodejs_broadcast_message Broadcast a message to all clients.
nodejs_enqueue_message Enqueue a message for sending at the end of the request.
nodejs_generate_content_token Generate a token for a piece of content.
nodejs_get_channels Get a list of active channels from the node.js server.
nodejs_get_client_socket_id Get the client socket id associated with this request.
nodejs_get_config Get nodejs server config.
nodejs_get_js_handlers Get a list of javascript handler files.
nodejs_get_socketio_js_config Return the path to the socket.io client js.
nodejs_get_url Get the URL of a Node.js callback.
nodejs_init Implements hook_init().
nodejs_is_valid_service_key Check if the given service key is valid.
nodejs_kick_user Kick a user off the node.js server.
nodejs_logout_user Logout any sockets associated with the given token from the node.js server.
nodejs_menu Implements hook_menu().
nodejs_message_handler Menu callback: handles all messages from Node.js server.
nodejs_nodejs_user_channels Implements hook_nodejs_user_channels().
nodejs_remove_user_from_channel Remove a user from a channel.
nodejs_send_channel_message Send a message to all users subscribed to a given channel.
nodejs_send_content_channel_message Send a content change message to a content channel.
nodejs_send_content_channel_token Send a content channel token to Node.js.
nodejs_send_message Send a message immediately.
nodejs_send_role_message Send a message to users in a role.
nodejs_send_user_message Send a message to given user.
nodejs_send_user_message_multiple Send a message to multiple users.
nodejs_set_user_presence_list Set the list of uids a user can see presence notifications for.
nodejs_user_logout Implements hook_user_logout().
nodejs_user_set_offline Set the user as offline.
nodejs_user_set_online Set the user as online.
_drupal_get_hash_salt
_drupal_hmac_base64
_drupal_random_bytes

Classes

Namesort descending Description
Nodejs