@geeqdev/geeq-sdk 0.0.1 → 0.1.1
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/.idea/geeq_sdk.iml +12 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/.nvmrc +1 -0
- package/CONTRIBUTING.md +20 -0
- package/example/.env.example +4 -0
- package/example/README.md +304 -0
- package/example/USAGE.md +74 -0
- package/example/index.html +12 -0
- package/example/package-lock.json +1557 -0
- package/example/package.json +25 -0
- package/example/scripts/browser-check.mjs +1421 -0
- package/example/scripts/match-derivation.mjs +65 -0
- package/example/src/App.vue +138 -0
- package/example/src/components/AuthorityPanel.vue +277 -0
- package/example/src/components/ChainPanel.vue +267 -0
- package/example/src/components/ChainState.vue +59 -0
- package/example/src/components/CopyButton.vue +25 -0
- package/example/src/components/CreateAssetPanel.vue +313 -0
- package/example/src/components/DigitalAssetsPanel.vue +68 -0
- package/example/src/components/EvidencePanel.vue +396 -0
- package/example/src/components/ExplorerPanel.vue +119 -0
- package/example/src/components/KeypairPanel.vue +182 -0
- package/example/src/components/PhrasePanel.vue +105 -0
- package/example/src/components/SocketWarning.vue +18 -0
- package/example/src/components/StepGate.vue +24 -0
- package/example/src/components/TransferPanel.vue +416 -0
- package/example/src/components/TxDeadline.vue +19 -0
- package/example/src/components/TxFee.vue +31 -0
- package/example/src/components/TxStatus.vue +105 -0
- package/example/src/composables/useSocketConnection.ts +80 -0
- package/example/src/env.d.ts +12 -0
- package/example/src/main.ts +5 -0
- package/example/src/pennies.ts +7 -0
- package/example/src/style.css +149 -0
- package/example/tsconfig.json +19 -0
- package/example/vite.config.ts +26 -0
- package/jest.config.js +18 -0
- package/package.json +1 -5
- package/scripts/copy-wasm.mjs +21 -0
- package/scripts/smoke.mjs +382 -0
- package/src/address/record-type.ts +77 -0
- package/src/address/wallet-address.ts +105 -0
- package/src/amount.ts +143 -0
- package/src/assert.ts +30 -0
- package/src/capnp/README.md +15 -0
- package/src/capnp/adminNetTx.capnp.ts +66 -0
- package/src/capnp/appLayBlock.capnp.ts +26 -0
- package/src/capnp/appLayLedger.capnp.ts +35 -0
- package/src/capnp/appLayRec.capnp.ts +159 -0
- package/src/capnp/chainStateView.capnp.ts +49 -0
- package/src/capnp/coinAcctRec.capnp.ts +193 -0
- package/src/capnp/genesisBlock.capnp.ts +56 -0
- package/src/capnp/hubContentStmt.capnp.ts +59 -0
- package/src/capnp/misc.capnp.ts +19 -0
- package/src/capnp/netActorRec.capnp.ts +64 -0
- package/src/capnp/nodeContentStmt.capnp.ts +62 -0
- package/src/capnp/nodePayloadList.capnp.ts +28 -0
- package/src/capnp/nodeTxList.capnp.ts +37 -0
- package/src/capnp/paramAcctRec.capnp.ts +669 -0
- package/src/capnp/payload.capnp.ts +261 -0
- package/src/capnp/proxyAcctRec.capnp.ts +259 -0
- package/src/capnp/relayContentStmt.capnp.ts +85 -0
- package/src/capnp/sysAcctRec.capnp.ts +47 -0
- package/src/capnp/userAppTx.capnp.ts +269 -0
- package/src/capnp/userValTx.capnp.ts +1999 -0
- package/src/capnp/valLayBlock.capnp.ts +93 -0
- package/src/capnp/valLayLedger.capnp.ts +80 -0
- package/src/chain/geeq-chain.ts +1094 -0
- package/src/chain/geeq-node.ts +105 -0
- package/src/chain/nonce-guard.ts +73 -0
- package/src/chain/select.ts +149 -0
- package/src/crypto/account-id.ts +83 -0
- package/src/crypto/keypair.ts +221 -0
- package/src/crypto/mnemonic.ts +69 -0
- package/src/encoding/base64.ts +58 -0
- package/src/encoding/hex.ts +42 -0
- package/src/errors.ts +122 -0
- package/src/index.ts +195 -0
- package/src/ledger/coin-accounts.ts +73 -0
- package/src/ledger/decode.ts +147 -0
- package/src/ledger/geeq-ledger.ts +184 -0
- package/src/ledger/net-actors.ts +129 -0
- package/src/ledger/nft-records.ts +49 -0
- package/src/ledger/params.ts +82 -0
- package/src/ledger/proxy-accounts.ts +51 -0
- package/src/net/discovery.ts +231 -0
- package/src/net/explorer.ts +123 -0
- package/src/net/read-ledger.ts +58 -0
- package/src/net/routes.ts +79 -0
- package/src/net/transport.ts +221 -0
- package/src/net/websocket.ts +94 -0
- package/src/runtime/bindings.ts +38 -0
- package/src/runtime/init.ts +49 -0
- package/src/runtime/wasm-loader.browser.ts +22 -0
- package/src/runtime/wasm-loader.node.ts +16 -0
- package/src/socket/geeq-socket.ts +827 -0
- package/src/socket/tracked-tx.ts +319 -0
- package/src/socket/tx-status.ts +131 -0
- package/src/tx/attestation.ts +222 -0
- package/src/tx/content.ts +82 -0
- package/src/tx/encode.ts +107 -0
- package/src/tx/expiry.ts +11 -0
- package/src/tx/fee.ts +31 -0
- package/src/tx/metadata.ts +100 -0
- package/src/tx/nft.ts +108 -0
- package/src/tx/proxy.ts +156 -0
- package/src/tx/transfer.ts +162 -0
- package/src/types.ts +503 -0
- package/src/wasm/README.md +22 -0
- package/src/wasm/browser/geeq_core.d.ts +1224 -0
- package/src/wasm/browser/geeq_core.js +6014 -0
- package/src/wasm/browser/geeq_core_bg.wasm +0 -0
- package/src/wasm/browser/geeq_core_bg.wasm.d.ts +447 -0
- package/src/wasm/nodejs/geeq_core.d.ts +753 -0
- package/src/wasm/nodejs/geeq_core.js +6016 -0
- package/src/wasm/nodejs/geeq_core_bg.wasm +0 -0
- package/src/wasm/nodejs/geeq_core_bg.wasm.d.ts +447 -0
- package/src/wasm/nodejs/package.json +3 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +60 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { MAX_UINT32, assertDuration } from '../assert'
|
|
2
|
+
import { originOf } from '../chain/select'
|
|
3
|
+
import { resolveAccountId } from '../crypto/account-id'
|
|
4
|
+
import type { AccountRef } from '../crypto/account-id'
|
|
5
|
+
import { fromBase64, toBase64 } from '../encoding/base64'
|
|
6
|
+
import {
|
|
7
|
+
GeeqError,
|
|
8
|
+
invalidArgument,
|
|
9
|
+
invalidKey,
|
|
10
|
+
socketClosed,
|
|
11
|
+
socketUnreachable,
|
|
12
|
+
} from '../errors'
|
|
13
|
+
import { socketRoute } from '../net/routes'
|
|
14
|
+
import {
|
|
15
|
+
WEBSOCKET_OPEN,
|
|
16
|
+
assertSocketSchemeReachable,
|
|
17
|
+
defaultWebSocketFactory,
|
|
18
|
+
opaqueSocketFailureMessage,
|
|
19
|
+
} from '../net/websocket'
|
|
20
|
+
import type { GeeqWebSocketFactory, GeeqWebSocketLike } from '../net/websocket'
|
|
21
|
+
import { GeeqTrackedTx, reportListenerError } from './tracked-tx'
|
|
22
|
+
import { parseTxStatus } from './tx-status'
|
|
23
|
+
import type { GeeqTxStatusEvent } from './tx-status'
|
|
24
|
+
|
|
25
|
+
const DEFAULT_OPEN_TIMEOUT_MS = 15_000
|
|
26
|
+
/** The edge proxy drops a socket idle for 60 s; the production wallet pings every 25. */
|
|
27
|
+
const DEFAULT_HEARTBEAT_MS = 25_000
|
|
28
|
+
const DEFAULT_RECONNECT_DELAY_MS = 1_000
|
|
29
|
+
const DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000
|
|
30
|
+
/**
|
|
31
|
+
* A transaction targets a block no more than ten behind the chain's head, so after about
|
|
32
|
+
* ten block intervals it has either been decided or never will be. Five minutes covers
|
|
33
|
+
* that at the ~20 s interval with room for a slow block.
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_TRACK_TIMEOUT_MS = 5 * 60_000
|
|
36
|
+
/** Long enough for the next commit to carry a restarted node's buffer forward. */
|
|
37
|
+
const DEFAULT_NONSANE_GRACE_MS = 30_000
|
|
38
|
+
const KEY_BYTES = 32
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* - `connecting` — the first handshake, inside {@link GeeqSocket.connect}.
|
|
42
|
+
* - `open` — subscribed and receiving.
|
|
43
|
+
* - `reconnecting` — dropped after being open; retrying with backoff. Anything the node
|
|
44
|
+
* sends meanwhile is lost.
|
|
45
|
+
* - `closed` — disposed. Final.
|
|
46
|
+
*/
|
|
47
|
+
export type GeeqSocketState = 'connecting' | 'open' | 'reconnecting' | 'closed'
|
|
48
|
+
|
|
49
|
+
/** One JSON frame from the node, as `{ event, payload }`. */
|
|
50
|
+
export interface GeeqSocketMessage {
|
|
51
|
+
readonly event: string
|
|
52
|
+
readonly payload: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GeeqSocketOptions {
|
|
56
|
+
/**
|
|
57
|
+
* The node the socket belongs to — normally `chain.endpoint`, which is also where the
|
|
58
|
+
* transactions go. Its socket is derived: `https://ip:2082` becomes
|
|
59
|
+
* `wss://ip:2082/ws/`.
|
|
60
|
+
*
|
|
61
|
+
* Supply this or {@link url}, not both. Use the node you submit to: only that node
|
|
62
|
+
* reports a `nonsane` refusal, though every node reports the final verdicts.
|
|
63
|
+
*/
|
|
64
|
+
readonly endpoint?: string | URL
|
|
65
|
+
/**
|
|
66
|
+
* The socket URL itself, when it is not the one derived from the node — a local
|
|
67
|
+
* harness reaching the socket server directly on its own port, for instance.
|
|
68
|
+
*/
|
|
69
|
+
readonly url?: string | URL
|
|
70
|
+
/** Defaults to `new globalThis.WebSocket(url)`. Supply one on Node 20 — see README. */
|
|
71
|
+
readonly webSocket?: GeeqWebSocketFactory
|
|
72
|
+
/**
|
|
73
|
+
* How long a handshake, or a send waiting on one, may take. Defaults to 15000. 0 waits
|
|
74
|
+
* for ever — including a first handshake that neither opens nor fails.
|
|
75
|
+
*/
|
|
76
|
+
readonly openTimeoutMs?: number
|
|
77
|
+
/** How often to send the keepalive `'ping'`. Defaults to 25000; 0 disables it. */
|
|
78
|
+
readonly heartbeatMs?: number
|
|
79
|
+
/** Reconnect after a drop. Defaults to true. */
|
|
80
|
+
readonly reconnect?: boolean
|
|
81
|
+
/** The first reconnect delay, doubled on each failure. Defaults to 1000. */
|
|
82
|
+
readonly reconnectDelayMs?: number
|
|
83
|
+
/** The ceiling on that delay. Defaults to 30000. */
|
|
84
|
+
readonly maxReconnectDelayMs?: number
|
|
85
|
+
/** Abandons the first handshake. Once connected, use {@link GeeqSocket.dispose}. */
|
|
86
|
+
readonly signal?: AbortSignal
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface GeeqSendOptions {
|
|
90
|
+
/**
|
|
91
|
+
* Re-send the frame on every reconnect. A node forgets a connection's subscriptions
|
|
92
|
+
* when it drops, so anything that subscribes should set this. Only the latest frame for
|
|
93
|
+
* each event name is kept.
|
|
94
|
+
*/
|
|
95
|
+
readonly replay?: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface GeeqListenOptions {
|
|
99
|
+
readonly blockNumber?: number
|
|
100
|
+
/** Defaults to true. */
|
|
101
|
+
readonly now?: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface GeeqTrackOptions {
|
|
105
|
+
/** How long to wait for a final status. Defaults to 300000. 0 waits for ever. */
|
|
106
|
+
readonly timeoutMs?: number
|
|
107
|
+
/** How long a `nonsane` may be overturned by a real verdict. Defaults to 30000. */
|
|
108
|
+
readonly nonsaneGraceMs?: number
|
|
109
|
+
/** More accounts to subscribe alongside the signer. */
|
|
110
|
+
readonly accounts?: readonly (AccountRef | string)[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Anything with the hash a status event names. Every transaction this SDK builds fits,
|
|
115
|
+
* and brings its signer's `publicKey` for the subscription.
|
|
116
|
+
*/
|
|
117
|
+
export interface GeeqTrackable {
|
|
118
|
+
readonly transactionHash: Uint8Array
|
|
119
|
+
readonly publicKey?: Uint8Array
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface OpenWaiter {
|
|
123
|
+
resolve(): void
|
|
124
|
+
reject(error: unknown): void
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type Listener<T> = (value: T) => void
|
|
128
|
+
|
|
129
|
+
function dispatch<T>(listeners: ReadonlySet<Listener<T>> | undefined, value: T): void {
|
|
130
|
+
if (listeners === undefined) return
|
|
131
|
+
// A copy, so a listener that removes itself does not skip its neighbour.
|
|
132
|
+
for (const listener of [...listeners]) {
|
|
133
|
+
try {
|
|
134
|
+
listener(value)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
reportListenerError(error)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function socketUrlOf(options: GeeqSocketOptions): string {
|
|
142
|
+
const raw =
|
|
143
|
+
options.url !== undefined
|
|
144
|
+
? String(options.url)
|
|
145
|
+
: socketRoute(originOf(options.endpoint as string | URL))
|
|
146
|
+
let parsed: URL
|
|
147
|
+
try {
|
|
148
|
+
parsed = new URL(raw)
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
throw invalidArgument(
|
|
151
|
+
'url must be an absolute socket URL such as wss://1.2.3.4:2082/ws/, received ' +
|
|
152
|
+
`${JSON.stringify(raw)}.`,
|
|
153
|
+
cause,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
|
157
|
+
throw invalidArgument(
|
|
158
|
+
`url must use ws:// or wss://, received ${JSON.stringify(raw)}. To derive the socket ` +
|
|
159
|
+
'from a node URL, pass it as endpoint instead.',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
return parsed.href
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Every key a node might file an account's events under.
|
|
167
|
+
*
|
|
168
|
+
* Nodes have keyed per-account events by the base64 public key and, since 2026-09, by the
|
|
169
|
+
* base64 account id; which one a socket server matches is not visible from here. So an
|
|
170
|
+
* account named by its public key is subscribed under both, as the production wallet does.
|
|
171
|
+
*/
|
|
172
|
+
function keysOf(ref: AccountRef | string): string[] {
|
|
173
|
+
if (typeof ref === 'string') {
|
|
174
|
+
const bytes = fromBase64(ref, 'account key')
|
|
175
|
+
if (bytes.length !== KEY_BYTES) {
|
|
176
|
+
throw invalidArgument(
|
|
177
|
+
`An account key must be the base64 of a ${KEY_BYTES}-byte public key or account id, ` +
|
|
178
|
+
`received ${bytes.length} bytes.`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
return [ref]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const accountId = toBase64(resolveAccountId(ref))
|
|
185
|
+
if (ref instanceof Uint8Array) return [accountId]
|
|
186
|
+
|
|
187
|
+
const { publicKey } = ref as { publicKey?: unknown }
|
|
188
|
+
if (publicKey === undefined) return [accountId]
|
|
189
|
+
if (!(publicKey instanceof Uint8Array) || publicKey.length !== KEY_BYTES) {
|
|
190
|
+
throw invalidKey(`publicKey must be ${KEY_BYTES} bytes.`)
|
|
191
|
+
}
|
|
192
|
+
return [toBase64(publicKey), accountId]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function closeQuietly(socket: GeeqWebSocketLike | null): void {
|
|
196
|
+
if (socket === null) return
|
|
197
|
+
try {
|
|
198
|
+
socket.close()
|
|
199
|
+
} catch {
|
|
200
|
+
// Already closing, or never opened. Either way there is nothing left to release.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A node's WebSocket: the channel through which it reports what happened to the
|
|
206
|
+
* transactions you sent, and pushes account and chain updates.
|
|
207
|
+
*
|
|
208
|
+
* One class for every use an application has for the socket:
|
|
209
|
+
*
|
|
210
|
+
* - {@link track} a transaction you are about to submit, and get its verdict;
|
|
211
|
+
* - {@link subscribe} to accounts and read their events with {@link on};
|
|
212
|
+
* - {@link send} any frame the node understands, and receive any it sends.
|
|
213
|
+
*
|
|
214
|
+
* The protocol's hazards are handled here, once: subscriptions replace rather than add,
|
|
215
|
+
* so the full set of keys is always sent; the node forgets a connection's subscriptions
|
|
216
|
+
* when it drops, so they are replayed on every reconnect before anything is announced;
|
|
217
|
+
* the edge proxy drops an idle socket, so it is kept alive; a transaction's first status
|
|
218
|
+
* is not always its last, so {@link GeeqTrackedTx} decides when it is final.
|
|
219
|
+
*
|
|
220
|
+
* ```ts
|
|
221
|
+
* const socket = await GeeqSocket.connect({ endpoint: chain.endpoint })
|
|
222
|
+
* const tx = await chain.createTransfer({ from, to, amount })
|
|
223
|
+
* const tracked = await socket.track(tx) // before submitting — always
|
|
224
|
+
* const response = await fetch(submitRoute(tx.nodeUrl), { … })
|
|
225
|
+
* if (!response.ok) tracked.refused({ status: response.status })
|
|
226
|
+
* const outcome = await tracked.settled()
|
|
227
|
+
* ```
|
|
228
|
+
*
|
|
229
|
+
* In a reactive framework, mark an instance raw (Vue's `markRaw`) rather than letting it
|
|
230
|
+
* be proxied — a proxied native WebSocket throws on every call.
|
|
231
|
+
*/
|
|
232
|
+
export class GeeqSocket {
|
|
233
|
+
/** The socket URL, normalised. */
|
|
234
|
+
readonly url: string
|
|
235
|
+
|
|
236
|
+
// TS-private and non-enumerable, never `#private` — see GeeqNode for why.
|
|
237
|
+
private readonly factory!: GeeqWebSocketFactory
|
|
238
|
+
private readonly openTimeoutMs!: number
|
|
239
|
+
private readonly heartbeatMs!: number
|
|
240
|
+
private readonly reconnects!: boolean
|
|
241
|
+
private readonly reconnectDelayMs!: number
|
|
242
|
+
private readonly maxReconnectDelayMs!: number
|
|
243
|
+
/** The frames in force, by event name, replayed on every connection. */
|
|
244
|
+
private readonly persisted!: Map<string, string>
|
|
245
|
+
/** What this connection has already been told, so a repeat is not sent twice. */
|
|
246
|
+
private readonly told!: Map<string, string>
|
|
247
|
+
private readonly keys!: Set<string>
|
|
248
|
+
private readonly tracked!: Map<string, GeeqTrackedTx>
|
|
249
|
+
private readonly eventListeners!: Map<string, Set<Listener<unknown>>>
|
|
250
|
+
private readonly messageListeners!: Set<Listener<GeeqSocketMessage>>
|
|
251
|
+
private readonly statusListeners!: Set<Listener<GeeqTxStatusEvent>>
|
|
252
|
+
private readonly stateListeners!: Set<Listener<GeeqSocketState>>
|
|
253
|
+
private readonly waiters!: Set<OpenWaiter>
|
|
254
|
+
private socket!: GeeqWebSocketLike | null
|
|
255
|
+
private current!: GeeqSocketState
|
|
256
|
+
private everOpened!: boolean
|
|
257
|
+
private attempts!: number
|
|
258
|
+
private openTimer!: ReturnType<typeof setTimeout> | null
|
|
259
|
+
private reconnectTimer!: ReturnType<typeof setTimeout> | null
|
|
260
|
+
private heartbeatTimer!: ReturnType<typeof setInterval> | null
|
|
261
|
+
|
|
262
|
+
/** @internal */
|
|
263
|
+
constructor(init: {
|
|
264
|
+
url: string
|
|
265
|
+
factory: GeeqWebSocketFactory
|
|
266
|
+
openTimeoutMs: number
|
|
267
|
+
heartbeatMs: number
|
|
268
|
+
reconnect: boolean
|
|
269
|
+
reconnectDelayMs: number
|
|
270
|
+
maxReconnectDelayMs: number
|
|
271
|
+
}) {
|
|
272
|
+
this.url = init.url
|
|
273
|
+
|
|
274
|
+
const hidden = (key: string, value: unknown, writable = false): void => {
|
|
275
|
+
Object.defineProperty(this, key, { value, enumerable: false, writable })
|
|
276
|
+
}
|
|
277
|
+
hidden('factory', init.factory)
|
|
278
|
+
hidden('openTimeoutMs', init.openTimeoutMs)
|
|
279
|
+
hidden('heartbeatMs', init.heartbeatMs)
|
|
280
|
+
hidden('reconnects', init.reconnect)
|
|
281
|
+
hidden('reconnectDelayMs', init.reconnectDelayMs)
|
|
282
|
+
hidden('maxReconnectDelayMs', init.maxReconnectDelayMs)
|
|
283
|
+
hidden('persisted', new Map())
|
|
284
|
+
hidden('told', new Map())
|
|
285
|
+
hidden('keys', new Set())
|
|
286
|
+
hidden('tracked', new Map())
|
|
287
|
+
hidden('eventListeners', new Map())
|
|
288
|
+
hidden('messageListeners', new Set())
|
|
289
|
+
hidden('statusListeners', new Set())
|
|
290
|
+
hidden('stateListeners', new Set())
|
|
291
|
+
hidden('waiters', new Set())
|
|
292
|
+
hidden('socket', null, true)
|
|
293
|
+
hidden('current', 'connecting', true)
|
|
294
|
+
hidden('everOpened', false, true)
|
|
295
|
+
hidden('attempts', 0, true)
|
|
296
|
+
hidden('openTimer', null, true)
|
|
297
|
+
hidden('reconnectTimer', null, true)
|
|
298
|
+
hidden('heartbeatTimer', null, true)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Open a node's socket and resolve once it is open.
|
|
303
|
+
*
|
|
304
|
+
* Throws `socket-unreachable` if the first handshake fails, rather than retrying
|
|
305
|
+
* quietly: a socket that never opened is almost always a certificate, a mixed-content
|
|
306
|
+
* page or a wrong URL, and retrying hides which. After that first open, a drop is
|
|
307
|
+
* retried automatically and reported through {@link onConnectionChange}.
|
|
308
|
+
*/
|
|
309
|
+
static async connect(options: GeeqSocketOptions): Promise<GeeqSocket> {
|
|
310
|
+
if ((options.endpoint === undefined) === (options.url === undefined)) {
|
|
311
|
+
throw invalidArgument(
|
|
312
|
+
'Provide exactly one of endpoint (the node you submit to — its socket URL is ' +
|
|
313
|
+
'derived from it) or url (a socket URL, such as ws://10.0.0.1:3100/ws/ for a ' +
|
|
314
|
+
'local harness).',
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
assertDuration(options.openTimeoutMs, 'openTimeoutMs')
|
|
318
|
+
assertDuration(options.heartbeatMs, 'heartbeatMs')
|
|
319
|
+
assertDuration(options.reconnectDelayMs, 'reconnectDelayMs')
|
|
320
|
+
assertDuration(options.maxReconnectDelayMs, 'maxReconnectDelayMs')
|
|
321
|
+
|
|
322
|
+
const url = socketUrlOf(options)
|
|
323
|
+
assertSocketSchemeReachable(url)
|
|
324
|
+
|
|
325
|
+
const { signal } = options
|
|
326
|
+
if (signal?.aborted === true) throw signal.reason
|
|
327
|
+
|
|
328
|
+
const socket = new GeeqSocket({
|
|
329
|
+
url,
|
|
330
|
+
factory: options.webSocket ?? defaultWebSocketFactory,
|
|
331
|
+
openTimeoutMs: options.openTimeoutMs ?? DEFAULT_OPEN_TIMEOUT_MS,
|
|
332
|
+
heartbeatMs: options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
|
|
333
|
+
reconnect: options.reconnect ?? true,
|
|
334
|
+
reconnectDelayMs: options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS,
|
|
335
|
+
maxReconnectDelayMs: options.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
let onAbort: (() => void) | undefined
|
|
339
|
+
try {
|
|
340
|
+
await new Promise<void>((resolve, reject) => {
|
|
341
|
+
// No timer of its own: the handshake's timeout is the deadline.
|
|
342
|
+
socket.waiters.add({ resolve, reject })
|
|
343
|
+
if (signal !== undefined) {
|
|
344
|
+
onAbort = () => reject(signal.reason)
|
|
345
|
+
signal.addEventListener('abort', onAbort)
|
|
346
|
+
}
|
|
347
|
+
socket.dial()
|
|
348
|
+
})
|
|
349
|
+
} catch (error) {
|
|
350
|
+
socket.dispose()
|
|
351
|
+
throw error
|
|
352
|
+
} finally {
|
|
353
|
+
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
|
|
354
|
+
}
|
|
355
|
+
return socket
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Where the connection stands. See {@link GeeqSocketState}. */
|
|
359
|
+
get state(): GeeqSocketState {
|
|
360
|
+
return this.current
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Every account key subscribed so far, base64. The set only grows. */
|
|
364
|
+
get subscriptions(): readonly string[] {
|
|
365
|
+
return [...this.keys]
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Subscribe to accounts' events: their transactions' statuses and record updates.
|
|
370
|
+
*
|
|
371
|
+
* Takes a keypair, a `{ publicKey }`, a 32-byte account id, a base64 key, or a list of
|
|
372
|
+
* them. The node has no unsubscribe and treats every subscription as a replacement, so
|
|
373
|
+
* this adds to the set this socket holds and sends all of it — calling it for one
|
|
374
|
+
* account never silences another. Resolves once the node has been told.
|
|
375
|
+
*/
|
|
376
|
+
async subscribe(
|
|
377
|
+
accounts: AccountRef | string | readonly (AccountRef | string)[],
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
this.assertOpenable('subscribe')
|
|
380
|
+
const list = (Array.isArray(accounts) ? accounts : [accounts]) as readonly (
|
|
381
|
+
| AccountRef
|
|
382
|
+
| string
|
|
383
|
+
)[]
|
|
384
|
+
// Resolve every key before adding any, so one bad entry does not half-apply the list.
|
|
385
|
+
const resolved = list.flatMap(keysOf)
|
|
386
|
+
for (const key of resolved) this.keys.add(key)
|
|
387
|
+
return this.send('client', { publicKeys: [...this.keys] }, { replay: true })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Ask for events from now, or from a block. Replayed on reconnect. */
|
|
391
|
+
async listen(options: GeeqListenOptions = {}): Promise<void> {
|
|
392
|
+
const { blockNumber } = options
|
|
393
|
+
if (
|
|
394
|
+
blockNumber !== undefined &&
|
|
395
|
+
(!Number.isInteger(blockNumber) || blockNumber < 0 || blockNumber > MAX_UINT32)
|
|
396
|
+
) {
|
|
397
|
+
throw invalidArgument(
|
|
398
|
+
`blockNumber must be a block number, received ${String(blockNumber)}.`,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
return this.send(
|
|
402
|
+
'listen',
|
|
403
|
+
{ ...(blockNumber === undefined ? {} : { blockNumber }), now: options.now ?? true },
|
|
404
|
+
{ replay: true },
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Subscribe to the whole chain's record feed — every account, fee and parameter update.
|
|
410
|
+
* What a block explorer or an indexer wants; an ordinary application does not.
|
|
411
|
+
*/
|
|
412
|
+
async subscribeBlockExplorer(): Promise<void> {
|
|
413
|
+
return this.send('block-explorer', undefined, { replay: true })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Send a frame, `{ event, payload }`, waiting for the connection if it is down.
|
|
418
|
+
*
|
|
419
|
+
* The escape hatch for anything the node understands that this class has no method
|
|
420
|
+
* for. Rejects with `socket-unreachable` if the socket does not reopen within
|
|
421
|
+
* `openTimeoutMs` — a replayed frame is still kept, and goes out when it does.
|
|
422
|
+
*/
|
|
423
|
+
async send(event: string, payload?: unknown, options: GeeqSendOptions = {}): Promise<void> {
|
|
424
|
+
this.assertOpenable('send')
|
|
425
|
+
if (typeof event !== 'string' || event === '') {
|
|
426
|
+
throw invalidArgument('event must be a non-empty string.')
|
|
427
|
+
}
|
|
428
|
+
const frame = JSON.stringify(payload === undefined ? { event } : { event, payload })
|
|
429
|
+
const replay = options.replay === true
|
|
430
|
+
if (replay) this.persisted.set(event, frame)
|
|
431
|
+
|
|
432
|
+
// Resolves only once the frame has actually gone out. A socket can be open by our
|
|
433
|
+
// books and already closing by its own — its close event not yet delivered — and a
|
|
434
|
+
// subscription reported as sent when it was not would break track()'s promise.
|
|
435
|
+
for (;;) {
|
|
436
|
+
await this.whenOpen()
|
|
437
|
+
// A newer frame for the same event may have replaced this one while we waited; the
|
|
438
|
+
// node must end up with the newest, and a replacement makes the older one moot.
|
|
439
|
+
const latest = replay ? (this.persisted.get(event) ?? frame) : frame
|
|
440
|
+
if (this.transmit(event, latest, replay)) return
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Follow a transaction to its verdict. Call it **before** submitting.
|
|
446
|
+
*
|
|
447
|
+
* Subscribes the signer — the node pushes a transaction's statuses only to connections
|
|
448
|
+
* subscribed to its account — and resolves once the node has been told, so a status
|
|
449
|
+
* cannot arrive before anyone is listening for it. If this throws, do not submit: a
|
|
450
|
+
* transaction sent with no way to hear its verdict leaves the user guessing.
|
|
451
|
+
*
|
|
452
|
+
* Tracking the same transaction twice returns the same handle.
|
|
453
|
+
*/
|
|
454
|
+
async track(tx: GeeqTrackable, options: GeeqTrackOptions = {}): Promise<GeeqTrackedTx> {
|
|
455
|
+
this.assertOpenable('track')
|
|
456
|
+
const hash = (tx as { transactionHash?: unknown } | null)?.transactionHash
|
|
457
|
+
if (!(hash instanceof Uint8Array) || hash.length !== KEY_BYTES) {
|
|
458
|
+
throw invalidArgument(
|
|
459
|
+
`tx must carry a ${KEY_BYTES}-byte transactionHash — pass the transaction the SDK ` +
|
|
460
|
+
'built.',
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
assertDuration(options.timeoutMs, 'timeoutMs')
|
|
464
|
+
assertDuration(options.nonsaneGraceMs, 'nonsaneGraceMs')
|
|
465
|
+
|
|
466
|
+
const accounts: (AccountRef | string)[] = [
|
|
467
|
+
...(tx.publicKey === undefined ? [] : [{ publicKey: tx.publicKey }]),
|
|
468
|
+
...(options.accounts ?? []),
|
|
469
|
+
]
|
|
470
|
+
if (accounts.length === 0) {
|
|
471
|
+
throw invalidArgument(
|
|
472
|
+
'This transaction does not say who signed it, so there is no account to subscribe ' +
|
|
473
|
+
'to for its status. Pass the signer in accounts.',
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const id = toBase64(hash)
|
|
478
|
+
const existing = this.tracked.get(id)
|
|
479
|
+
const tracked =
|
|
480
|
+
existing ??
|
|
481
|
+
new GeeqTrackedTx({
|
|
482
|
+
id,
|
|
483
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TRACK_TIMEOUT_MS,
|
|
484
|
+
nonsaneGraceMs: options.nonsaneGraceMs ?? DEFAULT_NONSANE_GRACE_MS,
|
|
485
|
+
detach: () => {
|
|
486
|
+
if (this.tracked.get(id) === tracked) this.tracked.delete(id)
|
|
487
|
+
},
|
|
488
|
+
})
|
|
489
|
+
// Registered before the subscription goes out, so nothing can slip between them.
|
|
490
|
+
this.tracked.set(id, tracked)
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
await this.subscribe(accounts)
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (existing === undefined) tracked.release()
|
|
496
|
+
throw error
|
|
497
|
+
}
|
|
498
|
+
return tracked
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Call `listener` with the payload of every frame named `event`, whatever it is —
|
|
503
|
+
* `'uut_status'`, `'coin-account-update'`, `'fee-update'`, or one this SDK has never
|
|
504
|
+
* heard of. Returns the function that removes it.
|
|
505
|
+
*/
|
|
506
|
+
on(event: string, listener: (payload: unknown) => void): () => void {
|
|
507
|
+
let listeners = this.eventListeners.get(event)
|
|
508
|
+
if (listeners === undefined) {
|
|
509
|
+
listeners = new Set()
|
|
510
|
+
this.eventListeners.set(event, listeners)
|
|
511
|
+
}
|
|
512
|
+
const set = listeners
|
|
513
|
+
set.add(listener)
|
|
514
|
+
return () => {
|
|
515
|
+
set.delete(listener)
|
|
516
|
+
if (set.size === 0 && this.eventListeners.get(event) === set) {
|
|
517
|
+
this.eventListeners.delete(event)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Call `listener` with every JSON frame the node sends. */
|
|
523
|
+
onMessage(listener: (message: GeeqSocketMessage) => void): () => void {
|
|
524
|
+
this.messageListeners.add(listener)
|
|
525
|
+
return () => {
|
|
526
|
+
this.messageListeners.delete(listener)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Call `listener` with every transaction status, tracked or not, already parsed. */
|
|
531
|
+
onTxStatus(listener: (event: GeeqTxStatusEvent) => void): () => void {
|
|
532
|
+
this.statusListeners.add(listener)
|
|
533
|
+
return () => {
|
|
534
|
+
this.statusListeners.delete(listener)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Call `listener` whenever {@link state} changes. */
|
|
539
|
+
onConnectionChange(listener: (state: GeeqSocketState) => void): () => void {
|
|
540
|
+
this.stateListeners.add(listener)
|
|
541
|
+
return () => {
|
|
542
|
+
this.stateListeners.delete(listener)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Close for good. Pending sends reject with `socket-closed`, and every transaction still
|
|
548
|
+
* being tracked settles `unknown` with reason `closed` — it may yet be decided, just
|
|
549
|
+
* not heard about here.
|
|
550
|
+
*/
|
|
551
|
+
dispose(): void {
|
|
552
|
+
if (this.current === 'closed') return
|
|
553
|
+
this.setState('closed')
|
|
554
|
+
|
|
555
|
+
for (const timer of [this.openTimer, this.reconnectTimer]) {
|
|
556
|
+
if (timer !== null) clearTimeout(timer)
|
|
557
|
+
}
|
|
558
|
+
this.openTimer = null
|
|
559
|
+
this.reconnectTimer = null
|
|
560
|
+
this.stopHeartbeat()
|
|
561
|
+
|
|
562
|
+
const socket = this.socket
|
|
563
|
+
this.socket = null
|
|
564
|
+
closeQuietly(socket)
|
|
565
|
+
|
|
566
|
+
const closed = socketClosed(`The socket at ${this.url} was disposed.`)
|
|
567
|
+
for (const waiter of [...this.waiters]) waiter.reject(closed)
|
|
568
|
+
this.waiters.clear()
|
|
569
|
+
|
|
570
|
+
for (const tracked of [...this.tracked.values()]) {
|
|
571
|
+
tracked.abandon(
|
|
572
|
+
'closed',
|
|
573
|
+
`The socket was closed while transaction ${tracked.id} was pending. It may still be ` +
|
|
574
|
+
'decided; check the ledger before sending it again.',
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
this.tracked.clear()
|
|
578
|
+
|
|
579
|
+
this.eventListeners.clear()
|
|
580
|
+
this.messageListeners.clear()
|
|
581
|
+
this.statusListeners.clear()
|
|
582
|
+
this.stateListeners.clear()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
toJSON(): Record<string, unknown> {
|
|
586
|
+
return {
|
|
587
|
+
url: this.url,
|
|
588
|
+
state: this.current,
|
|
589
|
+
subscriptions: this.keys.size,
|
|
590
|
+
tracking: this.tracked.size,
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
toString(): string {
|
|
595
|
+
return `GeeqSocket(${this.url}, ${this.current})`
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private assertOpenable(what: string): void {
|
|
599
|
+
if (this.current === 'closed') {
|
|
600
|
+
throw socketClosed(
|
|
601
|
+
`${what}() was called on a disposed socket (${this.url}). Connect a new one.`,
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private setState(next: GeeqSocketState): void {
|
|
607
|
+
if (this.current === next) return
|
|
608
|
+
this.current = next
|
|
609
|
+
dispatch(this.stateListeners, next)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Resolves when the socket is open, or rejects after `openTimeoutMs` or on dispose. */
|
|
613
|
+
private whenOpen(): Promise<void> {
|
|
614
|
+
if (this.current === 'open') return Promise.resolve()
|
|
615
|
+
if (this.current === 'closed') {
|
|
616
|
+
return Promise.reject(socketClosed(`The socket at ${this.url} was disposed.`))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return new Promise<void>((resolve, reject) => {
|
|
620
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
621
|
+
const waiter: OpenWaiter = {
|
|
622
|
+
resolve: () => {
|
|
623
|
+
if (timer !== null) clearTimeout(timer)
|
|
624
|
+
resolve()
|
|
625
|
+
},
|
|
626
|
+
reject: (error) => {
|
|
627
|
+
if (timer !== null) clearTimeout(timer)
|
|
628
|
+
reject(error)
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
if (this.openTimeoutMs > 0) {
|
|
632
|
+
timer = setTimeout(() => {
|
|
633
|
+
this.waiters.delete(waiter)
|
|
634
|
+
reject(
|
|
635
|
+
socketUnreachable(
|
|
636
|
+
`The socket at ${this.url} is ${this.current} and did not reopen within ` +
|
|
637
|
+
`${this.openTimeoutMs}ms. It keeps retrying; anything sent with replay ` +
|
|
638
|
+
'will go out when it does.',
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
}, this.openTimeoutMs)
|
|
642
|
+
}
|
|
643
|
+
this.waiters.add(waiter)
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private dial(): void {
|
|
648
|
+
let socket: GeeqWebSocketLike
|
|
649
|
+
try {
|
|
650
|
+
socket = this.factory(this.url)
|
|
651
|
+
} catch (cause) {
|
|
652
|
+
this.attemptFailed(
|
|
653
|
+
cause instanceof GeeqError
|
|
654
|
+
? cause
|
|
655
|
+
: socketUnreachable(opaqueSocketFailureMessage(this.url), cause),
|
|
656
|
+
)
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
this.socket = socket
|
|
661
|
+
// A new connection has been told nothing, so nothing may be suppressed as a repeat.
|
|
662
|
+
this.told.clear()
|
|
663
|
+
|
|
664
|
+
if (this.openTimeoutMs > 0) {
|
|
665
|
+
this.openTimer = setTimeout(() => {
|
|
666
|
+
if (this.socket !== socket) return
|
|
667
|
+
this.socket = null
|
|
668
|
+
closeQuietly(socket)
|
|
669
|
+
this.attemptFailed(
|
|
670
|
+
socketUnreachable(
|
|
671
|
+
`The socket at ${this.url} did not open within ${this.openTimeoutMs}ms. ` +
|
|
672
|
+
opaqueSocketFailureMessage(this.url),
|
|
673
|
+
),
|
|
674
|
+
)
|
|
675
|
+
}, this.openTimeoutMs)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// A superseded socket can still fire; only the current one may drive state.
|
|
679
|
+
socket.addEventListener('open', () => {
|
|
680
|
+
if (this.socket === socket) this.opened()
|
|
681
|
+
})
|
|
682
|
+
socket.addEventListener('message', (event) => {
|
|
683
|
+
if (this.socket === socket) this.receive(event.data)
|
|
684
|
+
})
|
|
685
|
+
socket.addEventListener('close', () => {
|
|
686
|
+
if (this.socket !== socket) return
|
|
687
|
+
this.socket = null
|
|
688
|
+
this.attemptFailed(socketUnreachable(opaqueSocketFailureMessage(this.url)))
|
|
689
|
+
})
|
|
690
|
+
socket.addEventListener('error', () => {
|
|
691
|
+
// A 'close' always follows, and that is where the drop is handled. The listener
|
|
692
|
+
// must still exist: the `ws` package throws an unhandled 'error' otherwise.
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private opened(): void {
|
|
697
|
+
if (this.openTimer !== null) clearTimeout(this.openTimer)
|
|
698
|
+
this.openTimer = null
|
|
699
|
+
this.everOpened = true
|
|
700
|
+
|
|
701
|
+
// Restore everything this connection should hold before announcing it, so a listener
|
|
702
|
+
// reacting to 'open' finds the subscriptions already in force. If it drops part way,
|
|
703
|
+
// the next connection replays the lot.
|
|
704
|
+
for (const [event, frame] of this.persisted) {
|
|
705
|
+
if (!this.transmit(event, frame, true)) return
|
|
706
|
+
}
|
|
707
|
+
// Only now is it a working connection. Resetting the backoff any earlier would let a
|
|
708
|
+
// connection that dies while its subscriptions are replayed be redialled every second
|
|
709
|
+
// for ever.
|
|
710
|
+
this.attempts = 0
|
|
711
|
+
this.startHeartbeat()
|
|
712
|
+
this.setState('open')
|
|
713
|
+
|
|
714
|
+
for (const waiter of [...this.waiters]) waiter.resolve()
|
|
715
|
+
this.waiters.clear()
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** A handshake failed, or an open socket dropped. */
|
|
719
|
+
private attemptFailed(error: GeeqError): void {
|
|
720
|
+
if (this.openTimer !== null) clearTimeout(this.openTimer)
|
|
721
|
+
this.openTimer = null
|
|
722
|
+
this.stopHeartbeat()
|
|
723
|
+
if (this.current === 'closed') return
|
|
724
|
+
|
|
725
|
+
if (!this.everOpened) {
|
|
726
|
+
// Still inside connect(): report it, and let connect() dispose.
|
|
727
|
+
for (const waiter of [...this.waiters]) waiter.reject(error)
|
|
728
|
+
this.waiters.clear()
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!this.reconnects) {
|
|
733
|
+
this.dispose()
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
this.setState('reconnecting')
|
|
738
|
+
if (this.reconnectTimer !== null) return
|
|
739
|
+
const delay = Math.min(this.reconnectDelayMs * 2 ** this.attempts, this.maxReconnectDelayMs)
|
|
740
|
+
this.attempts += 1
|
|
741
|
+
this.reconnectTimer = setTimeout(() => {
|
|
742
|
+
this.reconnectTimer = null
|
|
743
|
+
if (this.current !== 'closed') this.dial()
|
|
744
|
+
}, delay)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Put one frame on the current connection. True once it is there — or already was.
|
|
749
|
+
*
|
|
750
|
+
* False means the connection is gone. If our books still said it was open, it is
|
|
751
|
+
* treated as the drop it is, here and now, so the caller waits for the next connection
|
|
752
|
+
* instead of spinning on this one.
|
|
753
|
+
*/
|
|
754
|
+
private transmit(event: string, frame: string, dedupe: boolean): boolean {
|
|
755
|
+
// The node treats every subscription frame as a fresh subscribe, so an identical
|
|
756
|
+
// repeat on the same connection is dropped here instead.
|
|
757
|
+
if (dedupe && this.told.get(event) === frame) return true
|
|
758
|
+
|
|
759
|
+
const socket = this.socket
|
|
760
|
+
if (socket === null) return false
|
|
761
|
+
if (socket.readyState === WEBSOCKET_OPEN) {
|
|
762
|
+
try {
|
|
763
|
+
socket.send(frame)
|
|
764
|
+
if (dedupe) this.told.set(event, frame)
|
|
765
|
+
return true
|
|
766
|
+
} catch {
|
|
767
|
+
// Closing underneath us. Handled below, as for any other drop.
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
this.socket = null
|
|
772
|
+
closeQuietly(socket)
|
|
773
|
+
this.attemptFailed(socketUnreachable(opaqueSocketFailureMessage(this.url)))
|
|
774
|
+
return false
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private receive(data: unknown): void {
|
|
778
|
+
const text =
|
|
779
|
+
typeof data === 'string'
|
|
780
|
+
? data
|
|
781
|
+
: data instanceof ArrayBuffer || ArrayBuffer.isView(data)
|
|
782
|
+
? new TextDecoder().decode(data)
|
|
783
|
+
: undefined
|
|
784
|
+
if (text === undefined) return
|
|
785
|
+
|
|
786
|
+
let parsed: unknown
|
|
787
|
+
try {
|
|
788
|
+
parsed = JSON.parse(text)
|
|
789
|
+
} catch {
|
|
790
|
+
// Heartbeat replies and other non-JSON frames are not events.
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
if (parsed === null || typeof parsed !== 'object') return
|
|
794
|
+
const { event, payload } = parsed as { event?: unknown; payload?: unknown }
|
|
795
|
+
if (typeof event !== 'string') return
|
|
796
|
+
|
|
797
|
+
dispatch(this.messageListeners, Object.freeze({ event, payload }))
|
|
798
|
+
dispatch(this.eventListeners.get(event), payload)
|
|
799
|
+
|
|
800
|
+
if (event !== 'uut_status') return
|
|
801
|
+
const status = parseTxStatus(payload)
|
|
802
|
+
if (status === undefined) return
|
|
803
|
+
dispatch(this.statusListeners, status)
|
|
804
|
+
this.tracked.get(status.id)?.receive(status)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private startHeartbeat(): void {
|
|
808
|
+
this.stopHeartbeat()
|
|
809
|
+
if (this.heartbeatMs <= 0) return
|
|
810
|
+
this.heartbeatTimer = setInterval(() => {
|
|
811
|
+
const socket = this.socket
|
|
812
|
+
if (socket === null || socket.readyState !== WEBSOCKET_OPEN) return
|
|
813
|
+
try {
|
|
814
|
+
// A raw text frame, not JSON: there is no protocol ping, and a browser cannot send
|
|
815
|
+
// a control frame. Any reply is non-JSON and ignored.
|
|
816
|
+
socket.send('ping')
|
|
817
|
+
} catch {
|
|
818
|
+
// The close handler reconnects.
|
|
819
|
+
}
|
|
820
|
+
}, this.heartbeatMs)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
private stopHeartbeat(): void {
|
|
824
|
+
if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer)
|
|
825
|
+
this.heartbeatTimer = null
|
|
826
|
+
}
|
|
827
|
+
}
|