@tchain/tcabci-read-js-client 2.7.11 → 2.7.13

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.
package/errors.d.ts CHANGED
@@ -13,12 +13,26 @@ export declare const ALREADY_CONNECTED: ErrMessage,
13
13
  TRANSACTION_NOT_BROADCAST: ErrMessage
14
14
 
15
15
  export declare class FetchError extends Error {
16
- code: number
17
- response: Record<string, any>
16
+ readonly code: number
17
+ status: number
18
+ response?: Record<string, any>
18
19
  originError?: Error
20
+
19
21
  constructor(message: string)
22
+
20
23
  setStatus(status: number): FetchError
24
+
21
25
  setCode(code: number): FetchError
26
+
22
27
  setResponse(data: Record<string, any>): FetchError
28
+
23
29
  setOriginError(error: Error): FetchError
24
30
  }
31
+
32
+ export declare class CopiedError extends Error {
33
+ originName: string
34
+ originError: Error
35
+ originStack?: string
36
+
37
+ constructor(err: Error)
38
+ }
package/errors.js CHANGED
@@ -2,7 +2,7 @@ export const ALREADY_CONNECTED = 'Already connected',
2
2
  NOT_CONNECTED = 'Not connected',
3
3
  ERR_NETWORK = 'Network Error',
4
4
  INVALID_ARGUMENTS = 'Check your arguments',
5
- INVALID_ARGUMENT_WITH_CS = val => {
5
+ INVALID_ARGUMENT_WITH_CS = (val) => {
6
6
  return INVALID_ARGUMENTS + ' ' + val
7
7
  },
8
8
  NOT_SUBSCRIBED = 'Not subscribed',
@@ -31,6 +31,10 @@ export class CopiedError extends Error {
31
31
  }
32
32
 
33
33
  export class FetchError extends Error {
34
+ get code() {
35
+ return this.status
36
+ }
37
+
34
38
  status = -1
35
39
  response
36
40
  originError
package/http.d.ts CHANGED
@@ -1,9 +1,34 @@
1
- import { Request } from 'express'
1
+ import { ClientOptions, JSONValue, RequestOptions } from './types.js'
2
+
3
+ export type HTTPRequestInit = Omit<RequestInit, 'body'> & {
4
+ body?: string | ArrayBuffer | ArrayBufferView | null
5
+ }
6
+
7
+ export interface HTTPRequestOptions extends RequestOptions {
8
+ retry?: boolean
9
+ }
2
10
 
3
11
  export declare class HTTP {
4
- constructor(baseURL: string)
5
- setBaseURL(baseURL: string): HTTP
6
- request(uri: string, req: Request): Promise<Record<string, any>>
7
- handleResponse(response: Response): Promise<any>
8
- handleError(err: Error, custom: Record<string, any> | Error): Promise<any>
12
+ constructor(baseURL?: string | null, options?: ClientOptions)
13
+
14
+ setBaseURL(baseURL: string): this
15
+
16
+ abort(): void
17
+
18
+ request(
19
+ uri: string,
20
+ req?: HTTPRequestInit,
21
+ options?: HTTPRequestOptions
22
+ ): Promise<JSONValue>
23
+
24
+ handleResponse(
25
+ response: Response,
26
+ maxBytes?: number,
27
+ signal?: AbortSignal
28
+ ): Promise<JSONValue>
29
+
30
+ handleError(
31
+ err: Error & { status?: number; code?: number | string },
32
+ custom?: Record<string, Error> | Error | null
33
+ ): Promise<never>
9
34
  }
package/http.js CHANGED
@@ -1,83 +1,317 @@
1
+ import { SendThrow } from './util.js'
1
2
  import { ERR_NETWORK, FetchError, INVALID_ARGUMENTS } from './errors.js'
2
3
  import { Breaker } from './breaker.js'
3
- import { Debug } from '@streetbyters/js-debug'
4
+ import {
5
+ CLIENT_VERSION,
6
+ endpoint,
7
+ positiveInteger,
8
+ cloneAndFreeze,
9
+ byteLength
10
+ } from './validation.js'
4
11
 
5
12
  export class HTTP {
6
13
  _baseURL
7
- _version = 'v2.7.1'
8
14
  _breaker = new Breaker()
9
- constructor(baseURL = null) {
10
- this._baseURL = baseURL
15
+ _controllers = new Set()
16
+
17
+ /**
18
+ * @param {string|null} [baseURL=null]
19
+ * @param {import('./types.js').ClientOptions} [options={}] Frozen config snapshot.
20
+ */
21
+ constructor(baseURL = null, options = {}) {
22
+ this._options = Object.freeze({
23
+ timeoutMs: options.timeoutMs ?? 30000,
24
+ maxResponseBytes: options.maxResponseBytes ?? 64 * 1024 * 1024,
25
+ maxRequestBytes: options.maxRequestBytes ?? 16 * 1024 * 1024,
26
+ maxConcurrentRequests: options.maxConcurrentRequests ?? 32,
27
+ allowInsecure: options.allowInsecure === true
28
+ })
29
+
30
+ for (const name of [
31
+ 'timeoutMs',
32
+ 'maxResponseBytes',
33
+ 'maxRequestBytes',
34
+ 'maxConcurrentRequests'
35
+ ])
36
+ positiveInteger(this._options[name], name)
37
+
38
+ if (baseURL !== null) this.setBaseURL(baseURL)
11
39
  }
40
+
12
41
  setBaseURL(baseURL) {
13
- this._baseURL = baseURL
42
+ const url = endpoint(
43
+ baseURL,
44
+ ['https:', 'http:'],
45
+ this._options.allowInsecure
46
+ )
47
+
48
+ if (url.search) throw new TypeError('Endpoint query is not supported')
49
+
50
+ this._baseURL = url.href.replace(/\/$/, '')
51
+
14
52
  return this
15
53
  }
54
+
55
+ /**
56
+ * Abort all active requests without retaining caller abort reasons.
57
+ * @returns {void}
58
+ */
59
+ abort() {
60
+ for (const controller of this._controllers) controller.abort()
61
+ }
62
+
16
63
  /**
64
+ * Bounded JSON request with redirect rejection and a total deadline.
65
+ * Owns only encoded/copied body bytes; wipes them after fetch/retries settle.
66
+ * Fetch-owned response chunks and caller buffers are never mutated.
17
67
  * @param {string} uri
18
- * @param {RequestInit} req
19
- * @return {Promise<Record<string, any>>}
68
+ * @param {import('./http.js').HTTPRequestInit} [req={}]
69
+ * @param {import('./http.js').HTTPRequestOptions} [options={}]
70
+ * @returns {Promise<import('./types.js').JSONValue>} Deeply frozen, readonly JSON snapshot.
20
71
  */
21
- async request(uri, req) {
22
- req.cache = 'no-cache'
23
- req.headers = {
24
- Client: `tcabaci-read-js-client/${this._version}`
25
- }
26
- req.priority = 'high'
72
+ async request(uri, req = {}, options = {}) {
73
+ if (!this._baseURL) throw new TypeError('HTTP endpoint is not configured')
74
+
75
+ if (this._controllers.size >= this._options.maxConcurrentRequests)
76
+ throw new FetchError('Too many concurrent requests')
77
+
78
+ if (
79
+ typeof uri !== 'string' ||
80
+ !uri.startsWith('/') ||
81
+ uri.startsWith('//') ||
82
+ uri.includes('#')
83
+ )
84
+ throw new TypeError('Invalid request path')
85
+
86
+ const timeoutMs = positiveInteger(
87
+ options.timeoutMs ?? this._options.timeoutMs,
88
+ 'timeoutMs'
89
+ ),
90
+ maxResponseBytes = positiveInteger(
91
+ options.maxResponseBytes ?? this._options.maxResponseBytes,
92
+ 'maxResponseBytes'
93
+ ),
94
+ url = new URL(this._baseURL + uri)
95
+
96
+ if (url.origin !== new URL(this._baseURL).origin)
97
+ throw new TypeError('Invalid request origin')
98
+
99
+ const controller = new AbortController(),
100
+ callerSignal = options.signal ?? req.signal,
101
+ abort = () => controller.abort()
102
+ let timer, body, ownedBody
103
+
104
+ this._controllers.add(controller)
105
+
106
+ try {
107
+ const headers = new Headers(req.headers)
108
+
109
+ headers.set('Client', `tcabci-read-js-client/${CLIENT_VERSION}`)
110
+
111
+ if (callerSignal?.aborted) abort()
112
+ else callerSignal?.addEventListener('abort', abort, { once: true })
113
+
114
+ timer = setTimeout(abort, timeoutMs)
115
+ controller.signal.throwIfAborted()
116
+ body = req.body
117
+
118
+ if (typeof body === 'string') {
119
+ // The string belongs to the caller; only this encoded copy is ours.
120
+ if (byteLength(body) > this._options.maxRequestBytes)
121
+ SendThrow(new FetchError('Request body exceeds limit'))
122
+
123
+ ownedBody = new TextEncoder().encode(body)
124
+ } else if (ArrayBuffer.isView(body)) {
125
+ if (body.byteLength > this._options.maxRequestBytes)
126
+ SendThrow(new FetchError('Request body exceeds limit'))
127
+
128
+ ownedBody = new Uint8Array(
129
+ new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
130
+ )
131
+ } else if (body instanceof ArrayBuffer) {
132
+ if (body.byteLength > this._options.maxRequestBytes)
133
+ SendThrow(new FetchError('Request body exceeds limit'))
134
+
135
+ ownedBody = new Uint8Array(body.slice(0))
136
+ } else if (body != null) {
137
+ SendThrow(new TypeError('Request body must be text or bytes'))
138
+ }
139
+
140
+ if (ownedBody) {
141
+ if (ownedBody.byteLength > this._options.maxRequestBytes)
142
+ SendThrow(new FetchError('Request body exceeds limit'))
143
+
144
+ body = ownedBody
145
+
146
+ if (!headers.has('Content-Type'))
147
+ headers.set('Content-Type', 'application/json')
148
+ }
149
+
150
+ const method = (req.method ?? 'GET').toUpperCase(),
151
+ execute = async () => {
152
+ controller.signal.throwIfAborted()
153
+
154
+ let response
155
+
156
+ try {
157
+ response = await fetch(url.href, {
158
+ ...req,
159
+ method,
160
+ body,
161
+ headers,
162
+ cache: 'no-store',
163
+ redirect: 'error',
164
+ signal: controller.signal
165
+ })
166
+ } catch {
167
+ if (controller.signal.aborted)
168
+ throw new FetchError('Request aborted')
169
+
170
+ throw new FetchError(ERR_NETWORK)
171
+ }
172
+
173
+ return this.handleResponse(
174
+ response,
175
+ maxResponseBytes,
176
+ controller.signal
177
+ )
178
+ }
179
+
180
+ return await this._breaker.execute(
181
+ execute,
182
+ controller.signal,
183
+ options.retry ?? ['GET', 'HEAD'].includes(method)
184
+ )
185
+ } catch (error) {
186
+ if (controller.signal.aborted) throw new FetchError('Request aborted')
187
+
188
+ if (error instanceof FetchError) throw error
27
189
 
28
- return this._breaker
29
- .execute(() => {
30
- return fetch(this._baseURL + uri, req)
31
- })
32
- .then(response => this.handleResponse(response))
190
+ // Do not preserve input, URLs, AbortSignal.reason or transport causes.
191
+ throw new FetchError('Request failed')
192
+ } finally {
193
+ clearTimeout(timer)
194
+ callerSignal?.removeEventListener('abort', abort)
195
+ this._controllers.delete(controller)
196
+ ownedBody?.fill(0)
197
+ body = undefined
198
+ }
33
199
  }
34
200
 
35
201
  /**
202
+ * Read a bounded response, wipe owned byte copies, redact parsing failures.
203
+ * Returned JSON strings and engine/native copies cannot be reliably erased.
36
204
  * @param {Response} response
37
- * @return {Promise<*>}
205
+ * @param {number} [maxBytes]
206
+ * @param {AbortSignal} [signal]
207
+ * @returns {Promise<import('./types.js').JSONValue>} Deeply frozen, readonly JSON snapshot.
38
208
  */
39
- async handleResponse(response) {
40
- if (response.status >= 200 && response.status < 400) {
41
- return response.json()
209
+ async handleResponse(
210
+ response,
211
+ maxBytes = this._options.maxResponseBytes,
212
+ signal
213
+ ) {
214
+ if (response.status < 200 || response.status >= 300) {
215
+ await response.body?.cancel().catch(() => {})
216
+
217
+ throw new FetchError('HTTP request failed').setStatus(response.status)
42
218
  }
43
219
 
44
- try {
45
- const data = JSON.parse(await response.text())
220
+ if (response.status === 204 || response.status === 205) return null
46
221
 
47
- return Promise.reject(
48
- new FetchError(data.message)
49
- .setStatus(response.status)
50
- .setResponse(data)
222
+ const declared = Number(response.headers.get('content-length'))
223
+
224
+ if (declared > maxBytes) {
225
+ await response.body?.cancel().catch(() => {})
226
+
227
+ throw new FetchError('Response body exceeds limit').setStatus(
228
+ response.status
51
229
  )
52
- } catch (e) {
53
- Debug.error(e)
54
- return Promise.reject(
55
- new FetchError(response.statusText).setOriginError(e)
230
+ }
231
+
232
+ const reader = response.body?.getReader()
233
+
234
+ if (!reader)
235
+ throw new FetchError('Invalid JSON response').setStatus(response.status)
236
+
237
+ const chunks = []
238
+ let size = 0,
239
+ bytes,
240
+ completed = false
241
+ const abort = () => {
242
+ reader.cancel().catch(() => {})
243
+ }
244
+
245
+ signal?.addEventListener('abort', abort, { once: true })
246
+
247
+ try {
248
+ signal?.throwIfAborted()
249
+
250
+ while (true) {
251
+ const { value, done } = await reader.read()
252
+
253
+ signal?.throwIfAborted()
254
+
255
+ if (done) {
256
+ completed = true
257
+ break
258
+ }
259
+
260
+ size += value.byteLength
261
+
262
+ if (size > maxBytes)
263
+ SendThrow(new FetchError('Response body exceeds limit'))
264
+
265
+ // Fetch owns its chunks. Copy before retaining; never wipe shared input.
266
+ if (chunks.length >= 65536)
267
+ SendThrow(new FetchError('Response chunk limit exceeded'))
268
+
269
+ chunks.push(new Uint8Array(value))
270
+ }
271
+
272
+ bytes = new Uint8Array(size)
273
+
274
+ let offset = 0
275
+
276
+ for (const chunk of chunks) {
277
+ bytes.set(chunk, offset)
278
+ offset += chunk.length
279
+ }
280
+
281
+ return cloneAndFreeze(
282
+ JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
56
283
  )
284
+ } catch (error) {
285
+ if (signal?.aborted) throw new FetchError('Request aborted')
286
+
287
+ if (error instanceof FetchError) throw error.setStatus(response.status)
288
+
289
+ throw new FetchError('Invalid JSON response').setStatus(response.status)
290
+ } finally {
291
+ signal?.removeEventListener('abort', abort)
292
+
293
+ if (!completed) await reader.cancel().catch(() => {})
294
+
295
+ reader.releaseLock()
296
+
297
+ for (const chunk of chunks) chunk.fill(0)
298
+
299
+ chunks.length = 0
300
+ bytes?.fill(0)
57
301
  }
58
302
  }
59
303
 
60
- /**
61
- * @param {Error} err
62
- * @param {?Object|Error} custom
63
- * @return {Promise<Error>}
64
- */
65
304
  async handleError(err, custom = null) {
66
- const code = err.code
305
+ const code = err.status ?? err.code
67
306
 
68
- if (custom && typeof custom === 'object' && custom[code]) {
69
- return Promise.reject(custom[code])
70
- } else if (custom instanceof Error) {
71
- return Promise.reject(custom)
72
- }
307
+ if (custom instanceof Error) throw custom
73
308
 
74
- switch (code) {
75
- case 400:
76
- return Promise.reject(new Error(INVALID_ARGUMENTS))
77
- case 'ERR_NETWORK':
78
- return Promise.reject(new Error(ERR_NETWORK))
79
- default:
80
- return Promise.reject(err)
81
- }
309
+ if (custom && Object.hasOwn(custom, code)) throw custom[code]
310
+
311
+ if (code === 400) throw new Error(INVALID_ARGUMENTS)
312
+
313
+ if (code === 'ERR_NETWORK') throw new Error(ERR_NETWORK)
314
+
315
+ throw err
82
316
  }
83
317
  }
package/message.d.ts CHANGED
@@ -1,48 +1,51 @@
1
- import { TXType } from './transaction'
2
-
3
- export declare type MessageType = string
4
-
5
- export declare const SUBSCRIBEMessage: MessageType,
6
- UNSUBSCRIBEMessage: MessageType
7
-
8
- export declare type MType = number
9
-
10
- export declare const Block: MessageType,
11
- Transaction: MessageType,
12
- Subscription: MessageType,
13
- Listen: MessageType,
14
- MSG: MessageType
15
-
16
- export declare type MState = number
17
-
18
- export declare const OK: MState, FAIL: MState
19
-
20
- export declare class Message {
21
- constructor({
22
- isWeb,
23
- type,
24
- addrs,
25
- signedData,
26
- txTypes
27
- }?: {
28
- isWeb: boolean
29
- type: MessageType
30
- addrs: string[]
31
- signedData?: Record<string, string>
32
- txTypes?: TXType[]
1
+ import { TXType } from './transaction.js'
2
+ import { JSONValue, ParseResult } from './types.js'
3
+
4
+ export type MessageType = 'subscribe' | 'unsubscribe' | 0 | 1 | 2 | 3 | 4
5
+
6
+ export type MType = 0 | 1 | 2 | 3 | 4
7
+
8
+ export type MState = 1 | 2
9
+
10
+ export declare const SUBSCRIBEMessage: 'subscribe',
11
+ UNSUBSCRIBEMessage: 'unsubscribe',
12
+ Block: 0,
13
+ Transaction: 1,
14
+ Subscription: 2,
15
+ Listen: 3,
16
+ MSG: 4,
17
+ OK: 1,
18
+ FAIL: 2
19
+
20
+ export default class Message {
21
+ constructor(options?: {
22
+ isWeb?: boolean
23
+ type?: MessageType
24
+ addrs?: readonly string[]
25
+ signedData?: Readonly<Record<string, string>> | null
26
+ txTypes?: readonly TXType[] | null
27
+ data?: JSONValue
33
28
  })
34
- get IsWeb(): boolean
35
- get Type(): MessageType
36
- get Addresses(): string[] | null
37
- get SignedAddresses(): Record<string, string> | null
38
- get TXTypes(): TXType[] | null
39
- get Data(): any | null
40
- get State(): MState | null
41
-
42
- static FromJSON(value: string): { message?: Message; error?: Error }
43
- static FromObject(obj: Record<string, any>): {
44
- message?: Message
45
- error?: Error
46
- }
29
+
30
+ get IsWeb(): boolean | undefined
31
+
32
+ get Type(): MessageType | undefined
33
+
34
+ get Addresses(): readonly string[] | undefined
35
+
36
+ get SignedAddresses(): Readonly<Record<string, string>> | undefined
37
+
38
+ get TXTypes(): readonly TXType[] | undefined
39
+
40
+ get Data(): JSONValue | undefined
41
+
42
+ get State(): MState | undefined
43
+
44
+ static FromJSON(value: string): ParseResult<'message', Readonly<Message>>
45
+
46
+ static FromObject(
47
+ obj: Record<string, unknown>
48
+ ): ParseResult<'message', Readonly<Message>>
49
+
47
50
  ToJSON(): string
48
51
  }