@v3finance/monitoring-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +247 -2
  4. package/dist/cjs/client.d.ts +65 -0
  5. package/dist/cjs/client.d.ts.map +1 -0
  6. package/dist/cjs/client.js +89 -0
  7. package/dist/cjs/client.js.map +1 -0
  8. package/dist/cjs/core/errors.d.ts +64 -0
  9. package/dist/cjs/core/errors.d.ts.map +1 -0
  10. package/dist/cjs/core/errors.js +95 -0
  11. package/dist/cjs/core/errors.js.map +1 -0
  12. package/dist/cjs/core/http.d.ts +62 -0
  13. package/dist/cjs/core/http.d.ts.map +1 -0
  14. package/dist/cjs/core/http.js +151 -0
  15. package/dist/cjs/core/http.js.map +1 -0
  16. package/dist/cjs/core/pagination.d.ts +13 -0
  17. package/dist/cjs/core/pagination.d.ts.map +1 -0
  18. package/dist/cjs/core/pagination.js +15 -0
  19. package/dist/cjs/core/pagination.js.map +1 -0
  20. package/dist/cjs/generated/types.d.ts +324 -0
  21. package/dist/cjs/generated/types.d.ts.map +1 -0
  22. package/dist/cjs/generated/types.js +5 -0
  23. package/dist/cjs/generated/types.js.map +1 -0
  24. package/dist/cjs/index.d.ts +23 -0
  25. package/dist/cjs/index.d.ts.map +1 -0
  26. package/dist/cjs/index.js +50 -0
  27. package/dist/cjs/index.js.map +1 -0
  28. package/dist/cjs/package.json +1 -0
  29. package/dist/cjs/resources/account.d.ts +61 -0
  30. package/dist/cjs/resources/account.d.ts.map +1 -0
  31. package/dist/cjs/resources/account.js +75 -0
  32. package/dist/cjs/resources/account.js.map +1 -0
  33. package/dist/cjs/resources/addresses.d.ts +109 -0
  34. package/dist/cjs/resources/addresses.d.ts.map +1 -0
  35. package/dist/cjs/resources/addresses.js +137 -0
  36. package/dist/cjs/resources/addresses.js.map +1 -0
  37. package/dist/cjs/resources/catalog.d.ts +30 -0
  38. package/dist/cjs/resources/catalog.d.ts.map +1 -0
  39. package/dist/cjs/resources/catalog.js +46 -0
  40. package/dist/cjs/resources/catalog.js.map +1 -0
  41. package/dist/cjs/resources/clients.d.ts +84 -0
  42. package/dist/cjs/resources/clients.d.ts.map +1 -0
  43. package/dist/cjs/resources/clients.js +102 -0
  44. package/dist/cjs/resources/clients.js.map +1 -0
  45. package/dist/cjs/resources/deliveries.d.ts +50 -0
  46. package/dist/cjs/resources/deliveries.d.ts.map +1 -0
  47. package/dist/cjs/resources/deliveries.js +43 -0
  48. package/dist/cjs/resources/deliveries.js.map +1 -0
  49. package/dist/cjs/resources/transactions.d.ts +28 -0
  50. package/dist/cjs/resources/transactions.d.ts.map +1 -0
  51. package/dist/cjs/resources/transactions.js +35 -0
  52. package/dist/cjs/resources/transactions.js.map +1 -0
  53. package/dist/cjs/units.d.ts +29 -0
  54. package/dist/cjs/units.d.ts.map +1 -0
  55. package/dist/cjs/units.js +62 -0
  56. package/dist/cjs/units.js.map +1 -0
  57. package/dist/cjs/version.d.ts +5 -0
  58. package/dist/cjs/version.d.ts.map +1 -0
  59. package/dist/cjs/version.js +8 -0
  60. package/dist/cjs/version.js.map +1 -0
  61. package/dist/cjs/webhooks.d.ts +77 -0
  62. package/dist/cjs/webhooks.d.ts.map +1 -0
  63. package/dist/cjs/webhooks.js +128 -0
  64. package/dist/cjs/webhooks.js.map +1 -0
  65. package/dist/esm/client.d.ts +65 -0
  66. package/dist/esm/client.d.ts.map +1 -0
  67. package/dist/esm/client.js +85 -0
  68. package/dist/esm/client.js.map +1 -0
  69. package/dist/esm/core/errors.d.ts +64 -0
  70. package/dist/esm/core/errors.d.ts.map +1 -0
  71. package/dist/esm/core/errors.js +86 -0
  72. package/dist/esm/core/errors.js.map +1 -0
  73. package/dist/esm/core/http.d.ts +62 -0
  74. package/dist/esm/core/http.d.ts.map +1 -0
  75. package/dist/esm/core/http.js +146 -0
  76. package/dist/esm/core/http.js.map +1 -0
  77. package/dist/esm/core/pagination.d.ts +13 -0
  78. package/dist/esm/core/pagination.d.ts.map +1 -0
  79. package/dist/esm/core/pagination.js +12 -0
  80. package/dist/esm/core/pagination.js.map +1 -0
  81. package/dist/esm/generated/types.d.ts +324 -0
  82. package/dist/esm/generated/types.d.ts.map +1 -0
  83. package/dist/esm/generated/types.js +4 -0
  84. package/dist/esm/generated/types.js.map +1 -0
  85. package/dist/esm/index.d.ts +23 -0
  86. package/dist/esm/index.d.ts.map +1 -0
  87. package/dist/esm/index.js +20 -0
  88. package/dist/esm/index.js.map +1 -0
  89. package/dist/esm/resources/account.d.ts +61 -0
  90. package/dist/esm/resources/account.d.ts.map +1 -0
  91. package/dist/esm/resources/account.js +69 -0
  92. package/dist/esm/resources/account.js.map +1 -0
  93. package/dist/esm/resources/addresses.d.ts +109 -0
  94. package/dist/esm/resources/addresses.d.ts.map +1 -0
  95. package/dist/esm/resources/addresses.js +133 -0
  96. package/dist/esm/resources/addresses.js.map +1 -0
  97. package/dist/esm/resources/catalog.d.ts +30 -0
  98. package/dist/esm/resources/catalog.d.ts.map +1 -0
  99. package/dist/esm/resources/catalog.js +41 -0
  100. package/dist/esm/resources/catalog.js.map +1 -0
  101. package/dist/esm/resources/clients.d.ts +84 -0
  102. package/dist/esm/resources/clients.d.ts.map +1 -0
  103. package/dist/esm/resources/clients.js +97 -0
  104. package/dist/esm/resources/clients.js.map +1 -0
  105. package/dist/esm/resources/deliveries.d.ts +50 -0
  106. package/dist/esm/resources/deliveries.d.ts.map +1 -0
  107. package/dist/esm/resources/deliveries.js +39 -0
  108. package/dist/esm/resources/deliveries.js.map +1 -0
  109. package/dist/esm/resources/transactions.d.ts +28 -0
  110. package/dist/esm/resources/transactions.d.ts.map +1 -0
  111. package/dist/esm/resources/transactions.js +31 -0
  112. package/dist/esm/resources/transactions.js.map +1 -0
  113. package/dist/esm/units.d.ts +29 -0
  114. package/dist/esm/units.d.ts.map +1 -0
  115. package/dist/esm/units.js +58 -0
  116. package/dist/esm/units.js.map +1 -0
  117. package/dist/esm/version.d.ts +5 -0
  118. package/dist/esm/version.d.ts.map +1 -0
  119. package/dist/esm/version.js +5 -0
  120. package/dist/esm/version.js.map +1 -0
  121. package/dist/esm/webhooks.d.ts +77 -0
  122. package/dist/esm/webhooks.d.ts.map +1 -0
  123. package/dist/esm/webhooks.js +120 -0
  124. package/dist/esm/webhooks.js.map +1 -0
  125. package/package.json +66 -4
  126. package/src/client.ts +94 -0
  127. package/src/core/errors.ts +99 -0
  128. package/src/core/http.ts +193 -0
  129. package/src/core/pagination.ts +21 -0
  130. package/src/generated/types.ts +355 -0
  131. package/src/index.ts +60 -0
  132. package/src/resources/account.ts +87 -0
  133. package/src/resources/addresses.ts +190 -0
  134. package/src/resources/catalog.ts +47 -0
  135. package/src/resources/clients.ts +135 -0
  136. package/src/resources/deliveries.ts +69 -0
  137. package/src/resources/transactions.ts +33 -0
  138. package/src/units.ts +56 -0
  139. package/src/version.ts +5 -0
  140. package/src/webhooks.ts +133 -0
package/src/client.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { HttpClient, type ClientOptions, type RequestOptions } from './core/http.js'
2
+ import type { User } from './generated/types.js'
3
+ import { AccountResource, AuthResource, type SignInResult } from './resources/account.js'
4
+ import { AddressesResource } from './resources/addresses.js'
5
+ import { AssetsResource, NetworksResource } from './resources/catalog.js'
6
+ import { ClientsResource } from './resources/clients.js'
7
+ import { DeliveriesResource } from './resources/deliveries.js'
8
+ import { TransactionsResource } from './resources/transactions.js'
9
+ import { DEFAULT_BASE_URL } from './version.js'
10
+
11
+ /**
12
+ * Клиент API мониторинга адресов V3 Finance: адреса и подписки, история вебхуков, транзакции, ваш клиент и ключи.
13
+ * Только для сервера: ключ в браузере увидит любой.
14
+ *
15
+ * @example С ключом клиента — для бэкенда
16
+ * ```ts
17
+ * import { MonitoringClient } from '@v3finance/monitoring-sdk'
18
+ *
19
+ * const monitoring = new MonitoringClient({ apiKey: process.env.MONITORING_API_KEY })
20
+ * await monitoring.addresses.add(clientId, [{ network: 'tron', address: 'T…' }], { assets: ['USDT'], direction: 'in' })
21
+ * ```
22
+ *
23
+ * @example По паролю — для скриптов, которым нужны права пользователя
24
+ * ```ts
25
+ * const { client } = await MonitoringClient.signIn({ email, password })
26
+ * const { key } = await client.account.keys.create({ name: 'ci' })
27
+ * ```
28
+ */
29
+ export class MonitoringClient {
30
+ /** Кто вы и ваши личные ключи */
31
+ readonly account: AccountResource
32
+ /** Выход и смена пароля (для клиента, вошедшего по паролю) */
33
+ readonly auth: AuthResource
34
+ /** Сети */
35
+ readonly networks: NetworksResource
36
+ /** Нативные монеты и токены */
37
+ readonly assets: AssetsResource
38
+ /** Ваш клиент: URL вебхука, секрет, ключи, участники, тестовый вебхук */
39
+ readonly clients: ClientsResource
40
+ /** Адреса на мониторинге и подписка */
41
+ readonly addresses: AddressesResource
42
+ /** История вебхуков */
43
+ readonly deliveries: DeliveriesResource
44
+ /** Транзакция по хешу: статус, подтверждения, финальность */
45
+ readonly transactions: TransactionsResource
46
+
47
+ readonly #http: HttpClient
48
+
49
+ /**
50
+ * @param options - ключ (`apiKey`) или токен сессии (`sessionToken`), адрес API, таймаут, повторы
51
+ * @throws {@link MonitoringError} переданы и ключ, и токен; нет `fetch`
52
+ */
53
+ constructor(options: ClientOptions = {}) {
54
+ this.#http = new HttpClient(options, DEFAULT_BASE_URL)
55
+ this.account = new AccountResource(this.#http)
56
+ this.auth = new AuthResource(this.#http)
57
+ this.networks = new NetworksResource(this.#http)
58
+ this.assets = new AssetsResource(this.#http)
59
+ this.clients = new ClientsResource(this.#http)
60
+ this.addresses = new AddressesResource(this.#http)
61
+ this.deliveries = new DeliveriesResource(this.#http)
62
+ this.transactions = new TransactionsResource(this.#http)
63
+ }
64
+
65
+ /** Адрес API, с которым работает клиент */
66
+ get baseUrl(): string {
67
+ return this.#http.baseUrl
68
+ }
69
+
70
+ /**
71
+ * Вход по email и паролю: возвращает клиента с токеном сессии. Регистрации нет — пользователей заводит
72
+ * администратор сервиса. Не больше 3 попыток за 10 секунд с одного IP.
73
+ *
74
+ * @param options - настройки клиента, кроме учётных данных
75
+ * @throws {@link MonitoringApiError} 401 — неверный email или пароль
76
+ */
77
+ static async signIn(
78
+ credentials: { email: string; password: string },
79
+ options: Omit<ClientOptions, 'apiKey' | 'sessionToken'> = {},
80
+ requestOptions?: RequestOptions,
81
+ ): Promise<SignInResult & { client: MonitoringClient }> {
82
+ const http = new HttpClient(options, DEFAULT_BASE_URL)
83
+ const r = await http.request<{ token: string; user: User }>({
84
+ method: 'POST',
85
+ path: '/api/auth/sign-in/email',
86
+ body: credentials,
87
+ idempotent: false,
88
+ auth: false,
89
+ // повтор при отказе 429 только сожжёт попытки входа
90
+ options: { maxRetries: 0, ...requestOptions },
91
+ })
92
+ return { token: r.token, user: r.user, client: new MonitoringClient({ ...options, sessionToken: r.token }) }
93
+ }
94
+ }
@@ -0,0 +1,99 @@
1
+ import type { ApiErrorBody } from '../generated/types.js'
2
+
3
+ /** Ошибка по элементу запроса: номер элемента в переданном массиве и причина */
4
+ export type ErrorDetail = NonNullable<ApiErrorBody['error']['details']>[number]
5
+
6
+ /**
7
+ * Базовый класс всех ошибок SDK. Ловите его, чтобы перехватить любую ошибку SDK.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * try {
12
+ * await monitoring.addresses.add(clientId, items)
13
+ * } catch (e) {
14
+ * if (e instanceof MonitoringApiError && e.status === 400) console.log(e.details)
15
+ * else throw e
16
+ * }
17
+ * ```
18
+ */
19
+ export class MonitoringError extends Error {
20
+ constructor(message: string, options?: { cause?: unknown }) {
21
+ super(message, options)
22
+ this.name = 'MonitoringError'
23
+ }
24
+ }
25
+
26
+ /** API ответил ошибкой: HTTP 4xx или 5xx */
27
+ export class MonitoringApiError extends MonitoringError {
28
+ /** HTTP-статус ответа */
29
+ readonly status: number
30
+ /**
31
+ * Машинный код ошибки: `invalid_request`, `unauthorized`, `forbidden`, `not_found`, `conflict`,
32
+ * `rate_limited`, `upstream_error`, `upstream_rate_limited`, `unavailable`…
33
+ */
34
+ readonly code: string
35
+ /** Ошибки по элементам запроса (например, какой адрес из переданных не подошёл); пустой массив, если их нет */
36
+ readonly details: ErrorDetail[]
37
+ /** Идентификатор запроса (`cf-ray`) — укажите его, если обращаетесь в поддержку */
38
+ readonly requestId: string | undefined
39
+ /** Тело ответа как есть */
40
+ readonly body: unknown
41
+
42
+ constructor(init: { status: number; code: string; message: string; details?: ErrorDetail[]; requestId?: string; body?: unknown }) {
43
+ super(`${init.message} (HTTP ${init.status}, ${init.code})`)
44
+ this.name = 'MonitoringApiError'
45
+ this.status = init.status
46
+ this.code = init.code
47
+ this.details = init.details ?? []
48
+ this.requestId = init.requestId
49
+ this.body = init.body
50
+ }
51
+
52
+ /** Временная ошибка — лимит частоты или недоступный провайдер: запрос можно повторить позже */
53
+ get retryable(): boolean {
54
+ return this.status === 429 || this.status === 503
55
+ }
56
+ }
57
+
58
+ /** Ответа нет: сбой сети или соединения */
59
+ export class MonitoringConnectionError extends MonitoringError {
60
+ constructor(message: string, options?: { cause?: unknown }) {
61
+ super(message, options)
62
+ this.name = 'MonitoringConnectionError'
63
+ }
64
+ }
65
+
66
+ /** Запрос не уложился в таймаут (`timeoutMs`) */
67
+ export class MonitoringTimeoutError extends MonitoringConnectionError {
68
+ constructor(timeoutMs: number) {
69
+ super(`request timed out after ${timeoutMs} ms`)
70
+ this.name = 'MonitoringTimeoutError'
71
+ }
72
+ }
73
+
74
+ /** Подпись вебхука не прошла проверку: тело подделано, изменено по дороге или проверяется не тем секретом */
75
+ export class WebhookSignatureError extends MonitoringError {
76
+ constructor(message = 'webhook signature mismatch') {
77
+ super(message)
78
+ this.name = 'WebhookSignatureError'
79
+ }
80
+ }
81
+
82
+ /** Тело ошибки в любом из форматов API: `/v1`, старые пути (`{ok: false}`), вход (`{code, message}`) */
83
+ export function apiErrorFrom(status: number, body: unknown, requestId: string | undefined): MonitoringApiError {
84
+ const b = (body ?? {}) as Record<string, unknown>
85
+ const v1 = typeof b.error === 'object' && b.error !== null ? (b.error as Record<string, unknown>) : undefined
86
+ const message =
87
+ (typeof v1?.message === 'string' && v1.message) ||
88
+ (typeof b.error === 'string' && b.error) ||
89
+ (typeof b.message === 'string' && b.message) ||
90
+ `HTTP ${status}`
91
+ const code = (typeof v1?.code === 'string' && v1.code) || (typeof b.code === 'string' && b.code.toLowerCase()) || defaultCode(status)
92
+ const details = (Array.isArray(v1?.details) ? v1.details : Array.isArray(b.details) ? b.details : []) as ErrorDetail[]
93
+ return new MonitoringApiError({ status, code, message, details, requestId, body })
94
+ }
95
+
96
+ function defaultCode(status: number): string {
97
+ const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 409: 'conflict', 429: 'rate_limited' }
98
+ return codes[status] ?? (status >= 500 ? 'server_error' : 'http_error')
99
+ }
@@ -0,0 +1,193 @@
1
+ import { SDK_VERSION } from '../version.js'
2
+ import { apiErrorFrom, MonitoringConnectionError, MonitoringError, MonitoringTimeoutError } from './errors.js'
3
+
4
+ /** Настройки одного запроса — последний необязательный аргумент любого метода */
5
+ export interface RequestOptions {
6
+ /** Отмена запроса; повторы тоже прекращаются */
7
+ signal?: AbortSignal
8
+ /** Таймаут этого запроса, мс — вместо {@link ClientOptions.timeoutMs} */
9
+ timeoutMs?: number
10
+ /** Сколько раз повторить этот запрос — вместо {@link ClientOptions.maxRetries} */
11
+ maxRetries?: number
12
+ }
13
+
14
+ /** Настройки клиента */
15
+ export interface ClientOptions {
16
+ /**
17
+ * API-ключ: ключ клиента `ce_…` (адреса, история и транзакции своего клиента) или личный ключ `ce_user_…`
18
+ * (всё, что вы можете в панели, с вашими правами). Уходит в заголовке `x-api-key`.
19
+ */
20
+ apiKey?: string
21
+ /** Токен сессии из {@link MonitoringClient.signIn} — вместо `apiKey`. Уходит в `Authorization: Bearer` */
22
+ sessionToken?: string
23
+ /** Адрес API. По умолчанию `https://monitoring.v3.finance` */
24
+ baseUrl?: string
25
+ /** Таймаут запроса, мс. По умолчанию 30 000 */
26
+ timeoutMs?: number
27
+ /**
28
+ * Сколько раз повторять запрос после временной ошибки. По умолчанию 2.
29
+ *
30
+ * Повторяются ответы 429 и 503 (запрос не был выполнен), а сбои сети и таймауты — только у запросов,
31
+ * которые безопасно выполнить дважды (чтение, снятие и добавление адресов, отзыв ключа). Пауза — из
32
+ * `Retry-After`, иначе экспоненциальная с разбросом.
33
+ */
34
+ maxRetries?: number
35
+ /** Своя реализация `fetch` — для тестов, прокси, логирования. По умолчанию глобальный `fetch` */
36
+ fetch?: typeof fetch
37
+ /** Дополнительные заголовки каждого запроса */
38
+ headers?: Record<string, string>
39
+ }
40
+
41
+ type Query = Record<string, string | number | boolean | null | undefined>
42
+
43
+ /** Описание одного запроса к API */
44
+ export interface Call {
45
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
46
+ path: string
47
+ query?: Query
48
+ body?: unknown
49
+ /** Можно ли повторить после сбоя сети: повтор не изменит результат */
50
+ idempotent: boolean
51
+ /** Статусы, которые возвращаются как данные, а не ошибка (тестовый вебхук: 502 — «не доставлен») */
52
+ acceptStatuses?: number[]
53
+ /** false — запрос без учётных данных (вход) */
54
+ auth?: boolean
55
+ options?: RequestOptions | undefined
56
+ }
57
+
58
+ const DEFAULTS = { timeoutMs: 30_000, maxRetries: 2 }
59
+ const MAX_RETRY_DELAY_MS = 60_000
60
+
61
+ /** Транспорт SDK: адрес, авторизация, таймауты, повторы, разбор ошибок. Ресурсы ходят в API только через него */
62
+ export class HttpClient {
63
+ readonly baseUrl: string
64
+ readonly #apiKey: string | undefined
65
+ readonly #sessionToken: string | undefined
66
+ readonly #timeoutMs: number
67
+ readonly #maxRetries: number
68
+ readonly #fetch: typeof fetch
69
+ readonly #headers: Record<string, string>
70
+
71
+ constructor(options: ClientOptions, defaultBaseUrl: string) {
72
+ if (options.apiKey && options.sessionToken) throw new MonitoringError('pass either apiKey or sessionToken, not both')
73
+ this.baseUrl = (options.baseUrl ?? defaultBaseUrl).replace(/\/+$/, '')
74
+ this.#apiKey = options.apiKey
75
+ this.#sessionToken = options.sessionToken
76
+ this.#timeoutMs = options.timeoutMs ?? DEFAULTS.timeoutMs
77
+ this.#maxRetries = options.maxRetries ?? DEFAULTS.maxRetries
78
+ const f = options.fetch ?? globalThis.fetch
79
+ if (typeof f !== 'function') throw new MonitoringError('fetch is not available: use Node.js 20+ or pass options.fetch')
80
+ this.#fetch = f
81
+ this.#headers = { ...options.headers }
82
+ }
83
+
84
+ /** Выполняет запрос и возвращает разобранный JSON ответа */
85
+ async request<T>(call: Call): Promise<T> {
86
+ const url = this.#url(call.path, call.query)
87
+ const headers = this.#headersFor(call)
88
+ const body = call.body === undefined ? undefined : JSON.stringify(call.body)
89
+ const maxRetries = call.options?.maxRetries ?? this.#maxRetries
90
+ const timeoutMs = call.options?.timeoutMs ?? this.#timeoutMs
91
+
92
+ for (let attempt = 0; ; attempt++) {
93
+ let res: { status: number; headers: Headers; data: unknown }
94
+ try {
95
+ res = await this.#send(url, { method: call.method, headers, body }, timeoutMs, call.options?.signal)
96
+ } catch (e) {
97
+ if (call.options?.signal?.aborted) throw call.options.signal.reason
98
+ if (call.idempotent && attempt < maxRetries) {
99
+ await sleep(backoff(attempt), call.options?.signal)
100
+ continue
101
+ }
102
+ throw e
103
+ }
104
+ if ((res.status >= 200 && res.status < 300) || call.acceptStatuses?.includes(res.status)) return res.data as T
105
+ const error = apiErrorFrom(res.status, res.data, res.headers.get('cf-ray') ?? undefined)
106
+ // 429 и 503: запрос не выполнен — повтор безопасен для любого метода
107
+ if (error.retryable && attempt < maxRetries) {
108
+ await sleep(retryAfter(res.headers) ?? backoff(attempt), call.options?.signal)
109
+ continue
110
+ }
111
+ throw error
112
+ }
113
+ }
114
+
115
+ #url(path: string, query?: Query): string {
116
+ const url = new URL(this.baseUrl + path)
117
+ for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v))
118
+ return url.toString()
119
+ }
120
+
121
+ #headersFor(call: Call): Record<string, string> {
122
+ const headers: Record<string, string> = { accept: 'application/json', 'user-agent': `@v3finance/monitoring-sdk/${SDK_VERSION}`, ...this.#headers }
123
+ if (call.body !== undefined) headers['content-type'] = 'application/json'
124
+ if (call.auth !== false) {
125
+ if (this.#apiKey) headers['x-api-key'] = this.#apiKey
126
+ else if (this.#sessionToken) headers.authorization = `Bearer ${this.#sessionToken}`
127
+ else throw new MonitoringError('credentials required: pass apiKey or sessionToken to MonitoringClient')
128
+ }
129
+ return headers
130
+ }
131
+
132
+ /** Один запрос с таймаутом на всё — и соединение, и чтение тела */
133
+ async #send(url: string, init: RequestInit, timeoutMs: number, signal: AbortSignal | undefined) {
134
+ const controller = new AbortController()
135
+ const timer = setTimeout(() => controller.abort(new MonitoringTimeoutError(timeoutMs)), timeoutMs)
136
+ const onAbort = () => controller.abort(signal!.reason)
137
+ if (signal?.aborted) onAbort()
138
+ signal?.addEventListener('abort', onAbort, { once: true })
139
+ try {
140
+ const res = await this.#fetch(url, { ...init, signal: controller.signal })
141
+ const text = await res.text()
142
+ return { status: res.status, headers: res.headers, data: text ? parseJson(text) : undefined }
143
+ } catch (e) {
144
+ const reason: unknown = controller.signal.reason
145
+ if (reason instanceof MonitoringTimeoutError) throw reason
146
+ if (signal?.aborted) throw signal.reason
147
+ throw new MonitoringConnectionError(`request to ${new URL(url).origin} failed: ${(e as Error).message}`, { cause: e })
148
+ } finally {
149
+ clearTimeout(timer)
150
+ signal?.removeEventListener('abort', onAbort)
151
+ }
152
+ }
153
+ }
154
+
155
+ function parseJson(text: string): unknown {
156
+ try {
157
+ return JSON.parse(text)
158
+ } catch {
159
+ return text
160
+ }
161
+ }
162
+
163
+ /** Экспоненциальная пауза с разбросом: ~0,5 с, 1 с, 2 с… не больше 8 с */
164
+ function backoff(attempt: number): number {
165
+ return Math.min(8_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2)
166
+ }
167
+
168
+ /** Пауза из заголовка Retry-After: секунды или дата */
169
+ function retryAfter(headers: Headers): number | undefined {
170
+ const raw = headers.get('retry-after')
171
+ if (!raw) return undefined
172
+ const seconds = Number(raw)
173
+ const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw) - Date.now()
174
+ return Number.isFinite(ms) ? Math.min(Math.max(ms, 0), MAX_RETRY_DELAY_MS) : undefined
175
+ }
176
+
177
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
178
+ return new Promise((resolve, reject) => {
179
+ if (signal?.aborted) return reject(signal.reason)
180
+ const timer = setTimeout(() => {
181
+ signal?.removeEventListener('abort', onAbort)
182
+ resolve()
183
+ }, ms)
184
+ const onAbort = () => {
185
+ clearTimeout(timer)
186
+ reject(signal!.reason)
187
+ }
188
+ signal?.addEventListener('abort', onAbort, { once: true })
189
+ })
190
+ }
191
+
192
+ /** Сегмент пути: id клиента, хеш и т. п. */
193
+ export const seg = (value: string | number) => encodeURIComponent(String(value))
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Страница списка. `next` — курсор следующей страницы; `null` — страниц больше нет.
3
+ *
4
+ * @typeParam T - элемент списка
5
+ * @typeParam Cursor - тип курсора: строка (клиенты) или число (адреса, история)
6
+ */
7
+ export interface Page<T, Cursor = string | number> {
8
+ items: T[]
9
+ next: Cursor | null
10
+ }
11
+
12
+ /** Обходит все страницы по порядку, запрашивая следующую, только когда закончилась текущая */
13
+ export async function* paginate<T, Cursor>(fetchPage: (cursor: Cursor | undefined) => Promise<Page<T, Cursor>>): AsyncGenerator<T, void, undefined> {
14
+ let cursor: Cursor | undefined
15
+ for (;;) {
16
+ const page = await fetchPage(cursor)
17
+ yield* page.items
18
+ if (page.next === null || page.next === undefined) return
19
+ cursor = page.next
20
+ }
21
+ }