Браузерный клиент
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) тоже удаляет обработчик:
| Событие | Аргументы | Когда |
|---|---|---|
open | Event | Сокет готов, после автоматической аутентификации, если она настроена |
sessionRestoreFailed | Error, Event | Автоматическая аутентификация не удалась; срабатывает до open |
close | CloseEvent | Сокет закрылся |
error | Event | Ошибка сокета |
message | unknown, MessageEvent | Любая рассылка в виде полного конверта { id: "@", action, data } |
send | unknown | Данные отправлены; для команд — локальные метаданные без токена |
Используйте 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 |
host | location.host | Хост и порт |
query | нет | Объект или функция, возвращающая параметры запроса URL |
authenticationToken | нет | Провайдер токена для автоматической аутентификации |
requestTimeout | 300000 | Срок ожидания ответа в мс; 0 отключает |
requestOptions | нет | Опции запроса по умолчанию для каждого действия |
maxPendingRequests | 256 | Лимит ожидающих запросов и команд |
controlTimeout | 30000 | Срок ожидания ответа для authenticate() и logout(); 0 отключает |
waitConnectionTimeout | 30000 | Ожидание открытого сокета, в мс |
reconnect | true | Переподключаться в фоне после обрыва |
reconnectTimeout | 5000 | Базовая задержка переподключения в мс |
reconnectOnVisible | false | Пропускать backoff, когда вкладка становится видимой |
pingInterval | 30000 | Интервал heartbeat в мс (pingTimeout — устаревший псевдоним) |
pongTimeout | 30000 | Срок ожидания ответа heartbeat в мс; 0 отключает |
canConnect | нет | Условие, проверяемое перед подключением |
beforeConnect | нет | Асинхронный хук, ожидаемый перед подключением |
debug | false | Выводить в консоль отправленные и некорректные сообщения |
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-клиентом
Оба клиента используют один протокол, но различаются некоторыми правилами жизненного цикла. См. Протокол.