@tchain/tcabci-read-js-client 2.7.12 → 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,82 +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
+
16
55
  /**
17
- * @param {string} uri
18
- * @param {RequestInit} req
19
- * @return {Promise<Record<string, any>>}
56
+ * Abort all active requests without retaining caller abort reasons.
57
+ * @returns {void}
20
58
  */
21
- async request(uri, req) {
22
- req.cache = 'no-cache'
23
- if (!req.headers) req.headers = {}
24
- req.headers['Client'] = `tcabaci-read-js-client/${this._version}`
25
- req.priority = 'high'
26
-
27
- return this._breaker
28
- .execute(() => {
29
- return fetch(this._baseURL + uri, req)
30
- })
31
- .then(response => this.handleResponse(response))
59
+ abort() {
60
+ for (const controller of this._controllers) controller.abort()
32
61
  }
33
62
 
34
63
  /**
35
- * @param {Response} response
36
- * @return {Promise<*>}
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.
67
+ * @param {string} uri
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.
37
71
  */
38
- async handleResponse(response) {
39
- if (response.status >= 200 && response.status < 400) {
40
- return response.json()
41
- }
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)
42
105
 
43
106
  try {
44
- const data = JSON.parse(await response.text())
107
+ const headers = new Headers(req.headers)
45
108
 
46
- return Promise.reject(
47
- new FetchError(data.message)
48
- .setStatus(response.status)
49
- .setResponse(data)
50
- )
51
- } catch (e) {
52
- Debug.error(e)
53
- return Promise.reject(
54
- new FetchError(response.statusText).setOriginError(e)
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)
55
184
  )
185
+ } catch (error) {
186
+ if (controller.signal.aborted) throw new FetchError('Request aborted')
187
+
188
+ if (error instanceof FetchError) throw error
189
+
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
56
198
  }
57
199
  }
58
200
 
59
201
  /**
60
- * @param {Error} err
61
- * @param {?Object|Error} custom
62
- * @return {Promise<Error>}
202
+ * Read a bounded response, wipe owned byte copies, redact parsing failures.
203
+ * Returned JSON strings and engine/native copies cannot be reliably erased.
204
+ * @param {Response} response
205
+ * @param {number} [maxBytes]
206
+ * @param {AbortSignal} [signal]
207
+ * @returns {Promise<import('./types.js').JSONValue>} Deeply frozen, readonly JSON snapshot.
63
208
  */
64
- async handleError(err, custom = null) {
65
- const code = err.code
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)
218
+ }
219
+
220
+ if (response.status === 204 || response.status === 205) return null
221
+
222
+ const declared = Number(response.headers.get('content-length'))
66
223
 
67
- if (custom && typeof custom === 'object' && custom[code]) {
68
- return Promise.reject(custom[code])
69
- } else if (custom instanceof Error) {
70
- return Promise.reject(custom)
224
+ if (declared > maxBytes) {
225
+ await response.body?.cancel().catch(() => {})
226
+
227
+ throw new FetchError('Response body exceeds limit').setStatus(
228
+ response.status
229
+ )
71
230
  }
72
231
 
73
- switch (code) {
74
- case 400:
75
- return Promise.reject(new Error(INVALID_ARGUMENTS))
76
- case 'ERR_NETWORK':
77
- return Promise.reject(new Error(ERR_NETWORK))
78
- default:
79
- return Promise.reject(err)
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(() => {})
80
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))
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)
301
+ }
302
+ }
303
+
304
+ async handleError(err, custom = null) {
305
+ const code = err.status ?? err.code
306
+
307
+ if (custom instanceof Error) throw custom
308
+
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
81
316
  }
82
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
  }