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

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

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

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

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

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

const unsubscribe = client.onAction<User>("user:updated", updated => render(updated));

unsubscribe();
client.dispose();

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

Запросы​

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

Ответ считается ошибкой, если в нём есть строковое поле error, даже пустая строка. Ответ с совпадающим id и нестроковым error отклоняет запрос с TypeError и освобождает его слот. Некорректные конверты и рассылки игнорируются; для рассылки нужны непустая строка action и отсутствие error. SDK не проверяет прикладную структуру data.

Таймауты​

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

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

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

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

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

Повторы​

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

const user = await client.request<User>("user:get", { id: "42" }, {
timeout: 5000,
retry: { connectionClosed: 2, timeout: 1, jitter: 250 },
});
  • connectionClosed и timeout — число дополнительных попыток после ConnectionClosedError и RequestTimeoutError.
  • jitter добавляет перед каждым повтором случайную задержку до указанного числа миллисекунд.
  • Каждая попытка получает новый id запроса; запоздавший ответ на предыдущую попытку игнорируется.
  • Ответы сервера с ошибкой никогда не повторяются, как и команды аутентификации.
  • Повторы могут открыть соединение даже при reconnect: false. close() и dispose() отменяют их.
  • Счётчики должны быть неотрицательными безопасными целыми, а timeout и jitter — в диапазоне 0–2147483647 мс, иначе вызов отклоняется с RangeError ещё до подключения.

ConnectionClosedError.sent равно true, если хотя бы одна попытка дошла до WebSocket.send(). Это не доказывает, что сервер выполнил действие; false означает, что ничего не было отправлено, поэтому повтор не выполнит его дважды.

Опции по умолчанию для действия​

requestOptions задаёт значения по умолчанию по имени действия:

const READ = { timeout: 45000, retry: { connectionClosed: 3, timeout: 1, jitter: 2000 } };

const client = new DarkWs({
secure: true,
path: "/ws",
requestOptions: action => action.endsWith(":get") || action.endsWith(":list") ? READ : undefined,
});

Явно переданные опции переопределяют значения по умолчанию поле за полем (timeout, retry.connectionClosed, retry.timeout, retry.jitter). Числовой третий аргумент переопределяет только timeout. Если провайдер выбрасывает исключение, запрос отклоняется до отправки. authenticate() и logout() его не используют.

Лимит ожидающих запросов​

maxPendingRequests (256) ограничивает общее число ожидающих вызовов request(), authenticate() и logout(), включая ожидание подключения и задержки перед повторами. Один вызов занимает один слот, пока не завершится; повторы используют тот же слот. Лишний вызов сразу отклоняется с RangeError и ничего не отправляет. Увеличьте лимит, если приложению нужна большая степень параллелизма. pendingRequestCount показывает текущее использование.

Автоматическое восстановление сессии не учитывается, поэтому даже при заполненной очереди аутентификация пройдёт. Прямые вызовы send() тоже не учитываются.

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

Подписка на одно действие рассылки:

const off = client.onAction<Message>("message:created", message => append(message));
off(); // idempotent

Callback получает data напрямую (undefined, если поле опущено, null, если отправлено null). Аргумент типа во время выполнения не проверяется. Имена действий чувствительны к регистру. Подписки сохраняются при переподключении.

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

СобытиеАргументыКогда
openEventСокет готов, после автоматической аутентификации, если она настроена
sessionRestoreFailedError, EventАвтоматическая аутентификация не удалась; срабатывает до open
closeCloseEventСокет закрылся
errorEventОшибка сокета
messageunknown, MessageEventЛюбая рассылка в виде полного конверта { id: "@", action, data }
sendunknownДанные отправлены; для команд — локальные метаданные без токена

Используйте client.isCurrentSocket(event) в обработчиках open и close, чтобы игнорировать события сокета, который уже заменён. После освобождения клиента метод возвращает false для любого события:

client.on("close", event => {
if (!client.isCurrentSocket(event)) return;
showOffline(event.code);
});

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

Вручную​

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

Автоматически на каждом сокете​

authenticationToken вызывается при каждом открытии сокета:

const client = new DarkWs({
secure: true,
path: "/ws",
authenticationToken: () => tokenStore.current(),
});

Возвращённый токен отправляется как auth:<token>, а запросы в очереди и новые запросы удерживаются до auth:success, поэтому ни один запрос, сделанный во время переподключения, не дойдёт до сервера раньше аутентификации. open срабатывает после этого обмена. Если токен не возвращён, подключение будет анонимным.

Если сервер отвечает auth:failed или провайдер выбрасывает исключение, удерживаемые запросы отклоняются с этой ошибкой, срабатывает sessionRestoreFailed(error, event), и клиент продолжает работу анонимно. Если анонимная работа недопустима, закройте соединение в обработчике:

client.on("sessionRestoreFailed", (error, event) => {
if (client.isCurrentSocket(event)) client.close();
redirectToLogin();
});

sessionRestoreFailed срабатывает и при первом подключении. Оно не срабатывает при ручном authenticate(), при успехе, при намеренно пустом токене и для сокета, который уже закрыт. Вызов authenticate() из обработчика open такого порядка не гарантирует: запросы, поставленные в очередь во время переподключения, могут дойти до сервера раньше.

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

Токены в URL​

query добавляет параметры к URL сокета, и сервер при upgrade передаёт token своему аутентификатору:

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

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

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

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

Геттеры состояния: connected, closing, closed, pendingRequestCount.

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

close() отклоняет запросы и команды аутентификации/logout в очереди с ConnectionClosedError, не дожидаясь native close handshake. Флаг sent сохраняет сведения об отправке; повторы прекращаются даже при немедленном новом подключении. Нативное событие close может прийти позже.

Heartbeat​

Клиент отправляет текст ping каждые pingInterval (30 с). Если pong не приходит в течение pongTimeout (30 с, 0 отключает), сокет считается мёртвым: его запросы завершаются ошибкой, и клиент переподключается. Так обнаруживаются полуоткрытые соединения, которые TCP заметил бы гораздо позже. Сервер на .NET 8 полагается на этот трафик, чтобы не закрывать простаивающие соединения.

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

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

Опции​

Пропущенные опции и явно переданный undefined используют одинаковые значения по умолчанию. Значения таймеров должны быть конечными, от 0 до 2147483647 мс; pingInterval (или pingTimeout) должен быть больше 0. Ноль отключает дедлайны запросов, команд и PONG, а reconnect: false — автоматическое переподключение. Нулевая задержка ожидания соединения или переподключения означает немедленный дедлайн или повтор соответственно.

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

Id запросов формируются через crypto.randomUUID(), если он доступен, и через crypto.getRandomValues() в противном случае, поэтому клиент работает и на обычных страницах http:.

Клиент на всё приложение​

DarkWs.lazy() возвращает фасад LazyDarkWs, который создаёт клиент при первом использовании. Опции можно передать функцией — она читается в момент создания клиента:

export const dws = DarkWs.lazy(() => ({
secure: config.apiUrl.protocol === "https:",
host: config.apiUrl.host,
path: "/ws",
authenticationToken: () => session.token,
}));

const user = await dws.request<User>("user:get", { id: "42" });
dws.onAction<User>("user:updated", render);
  • Сам lazy() ничего не создаёт. Первый вызов request(), send(), authenticate(), logout(), on(), onAction() или reconnect() создаёт клиент и подключает его. Подписка тоже подключает, чтобы рассылки приходили сразу.
  • instance возвращает внутренний клиент, созданный без подключения, — для чтения состояния, например connected.
  • close(code) закрывает клиент; следующее использование подключит его снова. Закрывайте через фасад, а не через instance.close().
  • Фасад хранит собственные подписки. reset() освобождает клиент, отклоняя его ожидающие запросы, и заново читает опции. Подписки переходят на новый клиент, который сразу подключается, если предыдущий был запущен. Используйте его после смены пользователя или сервера.
  • reset(true) вдобавок удаляет все подписки и оставляет фасад в неактивном состоянии, что удобно между тестами.

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

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