Перейти к основному содержимому
Версия: 4.x

Браузерный клиент

darkws — ESM-клиент без зависимостей, с объявлениями типов TypeScript.

npm install darkws@4
import DarkWs, { ErrorResponse } from "darkws";

const client = new DarkWs({
secure: location.protocol === "https:",
path: "/ws",
}).connect();

await client.authenticate(auth.accessToken());
const user = await client.request<User>("user:get", { id: "42" });

const unsubscribe = client.on("message", message => console.log(message));

unsubscribe();
client.dispose();

Конструктор не открывает сокет и не запускает таймеры. Сокет открывает connect(); запросы, сделанные до открытия, ждут подключения. Запрос к клиенту без сокета сам открывает сокет.

Запросы​

const result = await client.request<Result, Input>("handler:action", input);
  • Promise разрешается полем data из ответа.
  • Ответ с ошибкой отклоняет promise с ErrorResponse: message — код ошибки, data — необязательные подробности, request — отправленный запрос.
  • Потеря соединения или отсутствие открытого сокета в течение waitConnectionTimeout отклоняет его с ConnectionClosedError; отсутствие ответа — с RequestTimeoutError.
try {
await client.request("message:delete", { id });
} catch (error) {
if (error instanceof ErrorResponse && error.message === "message:forbidden") {
showForbidden();
} else {
throw error;
}
}

Ответ считается ошибкой, если в нём есть поле error, даже пустое.

Запросы никогда не повторяются: запрос, сокет которого закрылся, завершается ошибкой, и приложение само решает, безопасно ли отправить его снова.

Таймауты​

Ожидание запроса состоит из двух фаз:

  • waitConnectionTimeout (30 с) ограничивает ожидание открытого сокета.
  • requestTimeout (5 мин) ограничивает ожидание ответа и отсчитывается с момента отправки.

Вызов может занять до суммы обоих значений. requestTimeout: 0 отключает истечение ожидания ответа: запрос остаётся в ожидании до ответа, разрыва соединения или освобождения клиента.

Таймаут ответа можно переопределить для отдельного вызова, передав число миллисекунд третьим аргументом:

await client.request("report:build", input, 60000);

Рассылки и события​

Рассылки приходят через событие message в виде полного конверта { id: "@", action, data }. Фильтруйте по действию сами:

const off = client.on("message", message => {
const { action, data } = message as { action: string; data?: unknown };
if (action === "message:created") append(data as Message);
});
off();

Конверт во время выполнения не проверяется. Имена действий чувствительны к регистру. Обработчики принадлежат клиенту, поэтому сохраняются при переподключении.

on(event, listener) подписывает на события клиента и возвращает функцию отписки; off(event, listener) тоже удаляет обработчик:

СобытиеАргументыКогда
openEventТекущий сокет открылся
closeCloseEventСокет закрылся
errorEventОшибка сокета
messageunknown, MessageEventЛюбая рассылка в виде полного конверта { id: "@", action, data }
sendunknownДанные отправлены: запрос для request(), сырые данные для send(), локальные метаданные без токена для команд

open срабатывает только для текущего сокета. close и error срабатывают и для сокета, который connect() уже заменил. Исключение в обработчике не мешает вызову остальных обработчиков.

Аутентификация​

Вручную​

await client.authenticate(token); // sends auth:<token>, waits for auth:success
await client.logout(); // sends logout, waits for logout:success
  • auth:failed отклоняет вызов с ErrorResponse.
  • Команды выполняются по одной. requestTimeout (5 мин, 0 отключает) ограничивает ожидание каждого ответа после отправки. Таймаут отклоняет вызов с RequestTimeoutError, закрывает сокет и отклоняет остальные запросы и команды в очереди на нём с ConnectionClosedError, чтобы запоздавший ответ не подтвердил не ту команду.
  • Клиент не хранит токен. После переподключения серверной сессии нет, если вы её не восстановите.

Восстановление сессии после переподключения​

У клиента нет опции автоматической аутентификации. Чтобы восстанавливать сессию на каждом сокете, аутентифицируйтесь заново при его открытии:

client.on("open", () => {
const token = tokenStore.current();
if (token) client.authenticate(token).catch(() => redirectToLogin());
});

Запросы, поставленные в очередь во время переподключения, отправляются при открытии сокета, ещё до вызова обработчиков open, поэтому они могут дойти до сервера раньше этой аутентификации и выполниться без сессии. Если это важно, передавайте токен с запросом upgrade (см. ниже).

open и connected сообщают о состоянии транспорта; они не доказывают, что сессия восстановлена.

Токены в URL​

query добавляет параметры к URL сокета, и сервер при upgrade передаёт token своему аутентификатору. Функция query вызывается для каждого сокета, поэтому каждое переподключение отправляет актуальное значение:

new DarkWs({ secure: true, path: "/ws", query: () => ({ token: ticket() }) });

URL попадают в логи прокси и логи доступа. Лучше использовать короткоживущий тикет или оставить URL чистым и вызывать authenticate() после подключения. После выхода очистите источник, из которого читают query или ваш обработчик open, чтобы переподключение не восстановило старые учётные данные.

Жизненный цикл соединения​

МетодПоведение
connect()Открывает сокет; существующий сокет закрывается и заменяется, а его ожидающие запросы отклоняются
reconnect()Сбрасывает backoff и подключается, если сокет не открыт и не открывается
close(code = 1000)Закрывает сокет и прекращает переподключение
dispose()Окончательно: останавливает таймеры, закрывает сокет, отклоняет ожидающие запросы
send(data, jsonify = true)Отправляет сырые данные, не дожидаясь ответа; сначала ждёт открытого сокета

Геттеры состояния: connected, closing, closed, pendingRequestCount (ожидающие запросы и команды, включая те, что ждут сокета).

  • После неожиданного закрытия клиент переподключается с экспоненциальным backoff и jitter, начиная с reconnectTimeout (5 с) и не более 30 с.
  • При reconnect: false фоновых повторов нет, но следующий запрос или send() откроет новый сокет.
  • После явного close() запросы и send() отклоняются с ConnectionClosedError; вызовите connect(), прежде чем снова пользоваться клиентом.

Heartbeat​

Клиент отправляет текст ping каждые pingTimeout (30 с), пока сокет открыт. Несмотря на название, pingTimeout — это интервал: клиент не ждёт и не проверяет ответ pong, поэтому сам по себе не обнаруживает полуоткрытые соединения. Сервер на .NET 8 полагается на этот трафик, чтобы не закрывать простаивающие соединения.

Условия подключения​

  • canConnect() проверяется перед каждой попыткой; пока он возвращает false, клиент повторяет проверку каждые 100 мс. Используйте его, чтобы дождаться условия, например появления сети.
  • beforeConnect() ожидается перед каждой попыткой, например чтобы обновить токен. Отклонение планирует переподключение.

Оба повтора планируются только при включённом reconnect.

Опции​

ОпцияПо умолчаниюНазначение
secureобязательнаwss при true, ws при false
pathобязательнаПуть endpoint, например /ws
hostlocation.hostХост и порт; обязателен вне окна браузера
queryнетОбъект или функция, возвращающая параметры запроса URL
requestTimeout300000Срок ожидания ответа в мс для запросов, authenticate() и logout(); 0 отключает
waitConnectionTimeout30000Ожидание открытого сокета, в мс
reconnecttrueПереподключаться в фоне после обрыва
reconnectTimeout5000Базовая задержка переподключения в мс
pingTimeout30000Интервал между ping heartbeat в мс
canConnectнетУсловие, проверяемое перед подключением
beforeConnectнетАсинхронный хук, ожидаемый перед подключением
debugfalseВыводить в консоль данные send(), некорректные сообщения и ошибки обработчиков

Id запросов формируются через crypto.randomUUID(), который браузеры предоставляют только в безопасных контекстах (HTTPS или localhost).

Сравнение с .NET-клиентом​

Оба клиента используют один протокол, но различаются некоторыми правилами жизненного цикла. См. Протокол.

  • .NET-клиент может автоматически аутентифицировать каждый сокет через AuthenticationTokenProvider; браузерный клиент аутентифицируется, только когда вы вызываете authenticate().
  • .NET-клиент считает соединение неудачным, если pong не пришёл в течение PongTimeout; браузерный клиент только отправляет ping.
  • .NET-клиент прекращает повторы после постоянных сбоев, таких как неудачная автоматическая аутентификация, HTTP 401 или 403 и ошибки протокола; браузерный клиент переподключается после каждого неожиданного закрытия, пока включён reconnect.
  • При выключенном переподключении запросы .NET завершаются ошибкой до явного ConnectAsync; следующий запрос браузерного клиента открывает новый сокет.