@totalis/hip4-client 0.0.0-stage → 0.65.2
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/CHANGELOG.md +1541 -0
- package/LICENSE +202 -0
- package/README.md +143 -2
- package/dist/api-keys.d.ts +118 -0
- package/dist/api-keys.js +104 -0
- package/dist/audience-contract.d.ts +52 -0
- package/dist/audience-contract.js +1 -0
- package/dist/base.d.ts +41 -0
- package/dist/base.js +60 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +15 -0
- package/dist/controller.d.ts +50 -0
- package/dist/controller.js +60 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +3 -0
- package/dist/flow-errors.d.ts +61 -0
- package/dist/flow-errors.js +70 -0
- package/dist/generated/account-read.d.ts +2009 -0
- package/dist/generated/account-read.js +1 -0
- package/dist/generated/api-keys.d.ts +1303 -0
- package/dist/generated/api-keys.js +1 -0
- package/dist/generated/browse.d.ts +938 -0
- package/dist/generated/browse.js +1 -0
- package/dist/generated/client.d.ts +73 -0
- package/dist/generated/client.js +1 -0
- package/dist/generated/errors.d.ts +404 -0
- package/dist/generated/errors.js +1342 -0
- package/dist/generated/maker.d.ts +47 -0
- package/dist/generated/maker.js +1 -0
- package/dist/generated/openapi.d.ts +8044 -0
- package/dist/generated/openapi.js +1 -0
- package/dist/generated/operator.d.ts +7 -0
- package/dist/generated/operator.js +1 -0
- package/dist/generated/realtime.d.ts +1416 -0
- package/dist/generated/realtime.js +1 -0
- package/dist/generated/vault-abi.d.ts +39 -0
- package/dist/generated/vault-abi.js +56 -0
- package/dist/generated/version.d.ts +23 -0
- package/dist/generated/version.js +143 -0
- package/dist/hypercore.d.ts +111 -0
- package/dist/hypercore.js +132 -0
- package/dist/maker.d.ts +205 -0
- package/dist/maker.js +608 -0
- package/dist/money.d.ts +59 -0
- package/dist/money.js +66 -0
- package/dist/operator.d.ts +4 -0
- package/dist/operator.js +4 -0
- package/dist/pagination.d.ts +20 -0
- package/dist/pagination.js +27 -0
- package/dist/partner.d.ts +59 -0
- package/dist/partner.js +57 -0
- package/dist/realtime.d.ts +19 -0
- package/dist/realtime.js +10 -0
- package/dist/request.d.ts +112 -0
- package/dist/request.js +187 -0
- package/dist/signer.d.ts +51 -0
- package/dist/signer.js +90 -0
- package/dist/stream.d.ts +133 -0
- package/dist/stream.js +318 -0
- package/dist/totalis.d.ts +656 -0
- package/dist/totalis.js +1160 -0
- package/dist/typed-data.d.ts +330 -0
- package/dist/typed-data.js +340 -0
- package/dist/ui.d.ts +54 -0
- package/dist/ui.js +77 -0
- package/dist/wallet-session.d.ts +45 -0
- package/dist/wallet-session.js +61 -0
- package/examples/browser-wallet.html +69 -0
- package/examples/browser-wallet.mjs +57 -0
- package/examples/node-bot.mjs +57 -0
- package/examples/onboard.mjs +132 -0
- package/examples/partner-frontend.mjs +44 -0
- package/examples/partner-server.mjs +22 -0
- package/package.json +104 -4
package/dist/signer.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { address, canonicalSignature, domainTypes } from "./typed-data.js";
|
|
2
|
+
function withoutDomainType(types) {
|
|
3
|
+
const { EIP712Domain: _, ...rest } = types;
|
|
4
|
+
return rest;
|
|
5
|
+
}
|
|
6
|
+
/** JSON for eth_signTypedData_v4: decimal strings for integers, and the domain type the method requires. */
|
|
7
|
+
function typedDataJson(typedData) {
|
|
8
|
+
return JSON.stringify({
|
|
9
|
+
...typedData,
|
|
10
|
+
types: {
|
|
11
|
+
EIP712Domain: domainTypes.EIP712Domain,
|
|
12
|
+
...withoutDomainType(typedData.types),
|
|
13
|
+
},
|
|
14
|
+
}, (_, value) => (typeof value === "bigint" ? value.toString() : value));
|
|
15
|
+
}
|
|
16
|
+
function hexUtf8(message) {
|
|
17
|
+
return `0x${Array.from(new TextEncoder().encode(message), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
18
|
+
}
|
|
19
|
+
/** Wallets answer with `unknown`; accept only a hex string. */
|
|
20
|
+
function hexString(value) {
|
|
21
|
+
if (typeof value !== "string" || !value.startsWith("0x"))
|
|
22
|
+
throw new TypeError("the wallet did not return a hex value");
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function firstAddress(value) {
|
|
26
|
+
return Array.isArray(value) && value.length > 0 ? hexString(value[0]) : undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Adapt a viem account or wallet client, an EIP-1193 provider, or an ethers
|
|
30
|
+
* signer. Signatures come back canonical (lowercase, v in {27, 28}).
|
|
31
|
+
*/
|
|
32
|
+
export function toTotalisSigner(input) {
|
|
33
|
+
const has = (name) => name in input && typeof input[name] === "function";
|
|
34
|
+
let signer;
|
|
35
|
+
if ("address" in input && typeof input.address === "function") {
|
|
36
|
+
signer = input;
|
|
37
|
+
}
|
|
38
|
+
else if ("getAddress" in input) {
|
|
39
|
+
const sign = (input.signTypedData ?? input._signTypedData)?.bind(input);
|
|
40
|
+
if (sign === undefined)
|
|
41
|
+
throw new TypeError("the ethers signer cannot sign typed data");
|
|
42
|
+
signer = {
|
|
43
|
+
address: () => input.getAddress(),
|
|
44
|
+
signTypedData: (typedData) => sign(typedData.domain, withoutDomainType(typedData.types), typedData.message),
|
|
45
|
+
signMessage: (message) => input.signMessage(message),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
else if ("getAddresses" in input) {
|
|
49
|
+
// A hoisted account (possibly local) signs itself; otherwise the wallet's first address signs over RPC.
|
|
50
|
+
const account = async () => input.account ?? (await input.getAddresses())[0] ?? Promise.reject(new Error("wallet client has no account"));
|
|
51
|
+
// viem's generic signatures are declared `never`; widen them once here.
|
|
52
|
+
const signTypedData = input.signTypedData.bind(input);
|
|
53
|
+
const signMessage = input.signMessage.bind(input);
|
|
54
|
+
signer = {
|
|
55
|
+
address: async () => {
|
|
56
|
+
const current = await account();
|
|
57
|
+
return typeof current === "string" ? current : current.address;
|
|
58
|
+
},
|
|
59
|
+
signTypedData: async (typedData) => hexString(await signTypedData({ account: await account(), ...typedData })),
|
|
60
|
+
signMessage: async (message) => hexString(await signMessage({ account: await account(), message })),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
else if ("address" in input && typeof input.address === "string") {
|
|
64
|
+
const account = input;
|
|
65
|
+
signer = {
|
|
66
|
+
address: async () => account.address,
|
|
67
|
+
signTypedData: (typedData) => account.signTypedData(typedData),
|
|
68
|
+
signMessage: (message) => account.signMessage({ message }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
else if (has("request")) {
|
|
72
|
+
// The provider's generic request is declared `never`; widen it once here.
|
|
73
|
+
const request = input.request.bind(input);
|
|
74
|
+
const account = async () => firstAddress(await request({ method: "eth_accounts" })) ??
|
|
75
|
+
firstAddress(await request({ method: "eth_requestAccounts" })) ?? Promise.reject(new Error("provider has no account"));
|
|
76
|
+
signer = {
|
|
77
|
+
address: account,
|
|
78
|
+
signTypedData: async (typedData) => hexString(await request({ method: "eth_signTypedData_v4", params: [await account(), typedDataJson(typedData)] })),
|
|
79
|
+
signMessage: async (message) => hexString(await request({ method: "personal_sign", params: [hexUtf8(message), await account()] })),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
throw new TypeError("unsupported signer: pass a viem account or wallet client, an EIP-1193 provider, or an ethers signer");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
address: async () => address((await signer.address()).toLowerCase()),
|
|
87
|
+
signTypedData: async (typedData) => canonicalSignature(await signer.signTypedData(typedData)),
|
|
88
|
+
signMessage: async (message) => canonicalSignature(await signer.signMessage(message)),
|
|
89
|
+
};
|
|
90
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type AccessToken, type PublicEnvironment } from "./base.js";
|
|
2
|
+
import type { components } from "./generated/realtime.js";
|
|
3
|
+
type Schemas = components["schemas"];
|
|
4
|
+
export type RealtimeCursor = Schemas["Cursor"];
|
|
5
|
+
export type StreamEvent = Schemas["Event"];
|
|
6
|
+
export type StreamEventType = StreamEvent["event_type"];
|
|
7
|
+
export type StreamEventOf<T extends StreamEventType> = StreamEvent extends infer E ? E extends {
|
|
8
|
+
readonly event_type: infer K;
|
|
9
|
+
} ? T extends K ? E : never : never : never;
|
|
10
|
+
export type StreamSnapshot = Schemas["Snapshot"];
|
|
11
|
+
export type StreamEventFrame = Schemas["EventFrame"];
|
|
12
|
+
/** What a stream follows: the account (optionally with every open RFQ), or one maker. The client manages the cursor. */
|
|
13
|
+
export type StreamSubscription = Omit<Schemas["AccountSubscription"], "cursor"> | Omit<Schemas["MakerSubscription"], "cursor">;
|
|
14
|
+
/** A server frame: `channel` names what `data` carries. */
|
|
15
|
+
export type StreamFrame = {
|
|
16
|
+
readonly channel: string;
|
|
17
|
+
readonly data?: unknown;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* `live` means subscribed and caught up: the snapshot applied, or every
|
|
21
|
+
* replayed event applied (with publication freshness, at the first
|
|
22
|
+
* publication checkpoint, which the server sends only after its replay).
|
|
23
|
+
* Anything else means books and pending reads are stale.
|
|
24
|
+
*/
|
|
25
|
+
export type StreamStatus = "idle" | "connecting" | "live" | "disconnected" | "closed";
|
|
26
|
+
/**
|
|
27
|
+
* Opens the socket. In a browser the credential travels as the
|
|
28
|
+
* `auth.<token>` subprotocol with the page's Origin; elsewhere (Node 22+, bots)
|
|
29
|
+
* as a bearer header, so `headers` carries Authorization and any extra headers.
|
|
30
|
+
*/
|
|
31
|
+
export type WebSocketFactory = (url: string, protocols: string[], headers: Record<string, string>) => WebSocket;
|
|
32
|
+
export type StreamOptions = {
|
|
33
|
+
readonly environment?: PublicEnvironment;
|
|
34
|
+
/** The API origin; without `streamBaseUrl` the stream connects to it. */
|
|
35
|
+
readonly baseUrl?: string;
|
|
36
|
+
/** The WebSocket origin, such as `wss://hip4-ws.totalis.trade`. Defaults to `baseUrl`, else the environment's stream host. */
|
|
37
|
+
readonly streamBaseUrl?: string;
|
|
38
|
+
readonly accessToken: AccessToken;
|
|
39
|
+
/** `publication_freshness` (1 s heartbeats and publication checkpoints) defaults on for an account, off for a maker. */
|
|
40
|
+
readonly subscription: StreamSubscription;
|
|
41
|
+
/**
|
|
42
|
+
* Resume after this cursor, for example one a bot persisted from `event.cursor`:
|
|
43
|
+
* the stream replays the retained events after it, or resets to a snapshot.
|
|
44
|
+
*/
|
|
45
|
+
readonly cursor?: RealtimeCursor;
|
|
46
|
+
readonly webSocket?: WebSocketFactory;
|
|
47
|
+
/** Extra handshake headers outside a browser, such as the Origin a browser session requires. */
|
|
48
|
+
readonly headers?: Record<string, string>;
|
|
49
|
+
readonly reconnect?: {
|
|
50
|
+
readonly baseDelayMs?: number;
|
|
51
|
+
readonly maxDelayMs?: number;
|
|
52
|
+
};
|
|
53
|
+
/** Reconnect after this long without a frame. Default 5000 ms with publication freshness, otherwise off. */
|
|
54
|
+
readonly silenceTimeoutMs?: number;
|
|
55
|
+
};
|
|
56
|
+
type Listeners = {
|
|
57
|
+
/** A new event, in order and deduplicated, with the frame that carried it. */
|
|
58
|
+
event: (event: StreamEvent, frame: StreamEventFrame) => void;
|
|
59
|
+
snapshot: (snapshot: StreamSnapshot) => void;
|
|
60
|
+
/** Local state built from earlier events is void; replace it from the snapshot that follows. */
|
|
61
|
+
reset: () => void;
|
|
62
|
+
status: (status: StreamStatus) => void;
|
|
63
|
+
/** Every parsed frame, including publication checkpoints, market refreshes and read invalidations. */
|
|
64
|
+
frame: (frame: StreamFrame) => void;
|
|
65
|
+
error: (error: unknown) => void;
|
|
66
|
+
};
|
|
67
|
+
/** The server closed the stream with a NEVER reason (for example SUBSCRIPTION_DENIED); it will not reconnect. */
|
|
68
|
+
export declare class StreamClosedError extends Error {
|
|
69
|
+
readonly reason: string;
|
|
70
|
+
readonly closeCode: number;
|
|
71
|
+
readonly name = "StreamClosedError";
|
|
72
|
+
constructor(reason: string, closeCode: number);
|
|
73
|
+
}
|
|
74
|
+
/** The account and maker stream subprotocol. */
|
|
75
|
+
export declare const realtimeProtocol = "totalis.realtime.v2";
|
|
76
|
+
export declare const marketStreamProtocol = "totalis.market.v2";
|
|
77
|
+
export declare function realtimeUrl(environment: PublicEnvironment, baseUrl?: string, streamBaseUrl?: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* The public market stream URL. `path` is `fallback_stream` from
|
|
80
|
+
* `GET /v1/market-data/config` (a path, `/v1/market-stream`), joined to the
|
|
81
|
+
* stream origin rather than the API origin.
|
|
82
|
+
*/
|
|
83
|
+
export declare function marketStreamUrl(environment: PublicEnvironment, baseUrl?: string, streamBaseUrl?: string, path?: string): string;
|
|
84
|
+
export declare function realtimeCredentialProtocol(accessToken: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* The authenticated realtime stream (`GET /v1/stream`) with snapshot, replay and
|
|
87
|
+
* reconnect. Delivery is at least once; this client orders and deduplicates by
|
|
88
|
+
* cursor and `event_id`, reconnects with jittered backoff from the last applied
|
|
89
|
+
* cursor, and follows each close reason's retry: BACKOFF resumes, REFRESH takes
|
|
90
|
+
* a fresh snapshot, NEVER stops. It works with the browser WebSocket and Node's.
|
|
91
|
+
*/
|
|
92
|
+
export declare class TotalisStream {
|
|
93
|
+
private readonly options;
|
|
94
|
+
status: StreamStatus;
|
|
95
|
+
/** The last applied cursor. Persist it to resume after a restart. */
|
|
96
|
+
cursor: RealtimeCursor | undefined;
|
|
97
|
+
private clockOffsetMs;
|
|
98
|
+
private socket;
|
|
99
|
+
private attempt;
|
|
100
|
+
private nextDelayMs;
|
|
101
|
+
private timer;
|
|
102
|
+
private silence;
|
|
103
|
+
private reauthorized;
|
|
104
|
+
/** A REPLAY resume with publication freshness: live waits for the first publication frame, which follows the replayed events. */
|
|
105
|
+
private replaying;
|
|
106
|
+
private readonly seen;
|
|
107
|
+
private readonly listeners;
|
|
108
|
+
constructor(options: StreamOptions);
|
|
109
|
+
on<K extends keyof Listeners>(type: K, listener: Listeners[K]): () => void;
|
|
110
|
+
start(): this;
|
|
111
|
+
close(): void;
|
|
112
|
+
/** The venue clock in ms, from the last subscription response or publication checkpoint's `server_time`. */
|
|
113
|
+
serverNow(): number;
|
|
114
|
+
/** Resolves once the stream is live (starting it if needed); rejects if it closes for good. */
|
|
115
|
+
live(signal?: AbortSignal): Promise<void>;
|
|
116
|
+
/** The first event the predicate accepts. Rejects if the stream closes for good or the signal aborts. */
|
|
117
|
+
next<E extends StreamEvent>(predicate: (event: StreamEvent) => event is E, signal?: AbortSignal): Promise<E>;
|
|
118
|
+
next(predicate: (event: StreamEvent) => boolean, signal?: AbortSignal): Promise<StreamEvent>;
|
|
119
|
+
private until;
|
|
120
|
+
private emit;
|
|
121
|
+
private setStatus;
|
|
122
|
+
private reset;
|
|
123
|
+
private apply;
|
|
124
|
+
private freshness;
|
|
125
|
+
private connect;
|
|
126
|
+
private subscribe;
|
|
127
|
+
private onFrame;
|
|
128
|
+
private goLive;
|
|
129
|
+
private onClose;
|
|
130
|
+
private disconnected;
|
|
131
|
+
private watchSilence;
|
|
132
|
+
}
|
|
133
|
+
export {};
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { streamServerUrl } from "./base.js";
|
|
2
|
+
import { errorRegistry } from "./generated/errors.js";
|
|
3
|
+
import { backoffDelay } from "./request.js";
|
|
4
|
+
const inBrowser = () => typeof globalThis.document !== "undefined";
|
|
5
|
+
// Node's WebSocket (undici) and Bun accept `{ protocols, headers }` in place of protocols.
|
|
6
|
+
const openWebSocket = (url, protocols, headers) => inBrowser() ? new WebSocket(url, protocols) : new WebSocket(url, { protocols, headers });
|
|
7
|
+
/** The server closed the stream with a NEVER reason (for example SUBSCRIPTION_DENIED); it will not reconnect. */
|
|
8
|
+
export class StreamClosedError extends Error {
|
|
9
|
+
reason;
|
|
10
|
+
closeCode;
|
|
11
|
+
name = "StreamClosedError";
|
|
12
|
+
constructor(reason, closeCode) {
|
|
13
|
+
super(`stream closed: ${reason || closeCode}`);
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
this.closeCode = closeCode;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The account and maker stream subprotocol. */
|
|
19
|
+
export const realtimeProtocol = "totalis.realtime.v2";
|
|
20
|
+
export const marketStreamProtocol = "totalis.market.v2";
|
|
21
|
+
export function realtimeUrl(environment, baseUrl, streamBaseUrl) {
|
|
22
|
+
return `${streamServerUrl(environment, baseUrl, streamBaseUrl)}/v1/stream`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The public market stream URL. `path` is `fallback_stream` from
|
|
26
|
+
* `GET /v1/market-data/config` (a path, `/v1/market-stream`), joined to the
|
|
27
|
+
* stream origin rather than the API origin.
|
|
28
|
+
*/
|
|
29
|
+
export function marketStreamUrl(environment, baseUrl, streamBaseUrl, path = "/v1/market-stream") {
|
|
30
|
+
return `${streamServerUrl(environment, baseUrl, streamBaseUrl)}${path}`;
|
|
31
|
+
}
|
|
32
|
+
export function realtimeCredentialProtocol(accessToken) {
|
|
33
|
+
const bytes = new TextEncoder().encode(accessToken);
|
|
34
|
+
let binary = "";
|
|
35
|
+
for (const byte of bytes)
|
|
36
|
+
binary += String.fromCharCode(byte);
|
|
37
|
+
return `auth.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
|
|
38
|
+
}
|
|
39
|
+
const sameStream = (a, b) => a.stream_id === b.stream_id && a.generation_id === b.generation_id;
|
|
40
|
+
/**
|
|
41
|
+
* The authenticated realtime stream (`GET /v1/stream`) with snapshot, replay and
|
|
42
|
+
* reconnect. Delivery is at least once; this client orders and deduplicates by
|
|
43
|
+
* cursor and `event_id`, reconnects with jittered backoff from the last applied
|
|
44
|
+
* cursor, and follows each close reason's retry: BACKOFF resumes, REFRESH takes
|
|
45
|
+
* a fresh snapshot, NEVER stops. It works with the browser WebSocket and Node's.
|
|
46
|
+
*/
|
|
47
|
+
export class TotalisStream {
|
|
48
|
+
options;
|
|
49
|
+
status = "idle";
|
|
50
|
+
/** The last applied cursor. Persist it to resume after a restart. */
|
|
51
|
+
cursor;
|
|
52
|
+
clockOffsetMs = 0;
|
|
53
|
+
socket;
|
|
54
|
+
attempt = 0;
|
|
55
|
+
nextDelayMs;
|
|
56
|
+
timer;
|
|
57
|
+
silence;
|
|
58
|
+
reauthorized = false;
|
|
59
|
+
/** A REPLAY resume with publication freshness: live waits for the first publication frame, which follows the replayed events. */
|
|
60
|
+
replaying = false;
|
|
61
|
+
seen = [];
|
|
62
|
+
listeners = {
|
|
63
|
+
event: new Set(), snapshot: new Set(), reset: new Set(), status: new Set(), frame: new Set(), error: new Set(),
|
|
64
|
+
};
|
|
65
|
+
constructor(options) {
|
|
66
|
+
this.options = options;
|
|
67
|
+
this.cursor = options.cursor;
|
|
68
|
+
}
|
|
69
|
+
on(type, listener) {
|
|
70
|
+
this.listeners[type].add(listener);
|
|
71
|
+
return () => this.listeners[type].delete(listener);
|
|
72
|
+
}
|
|
73
|
+
start() {
|
|
74
|
+
if (this.status === "idle")
|
|
75
|
+
void this.connect();
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
close() {
|
|
79
|
+
this.setStatus("closed");
|
|
80
|
+
clearTimeout(this.timer);
|
|
81
|
+
clearTimeout(this.silence);
|
|
82
|
+
const socket = this.socket;
|
|
83
|
+
this.socket = undefined;
|
|
84
|
+
socket?.close(1000);
|
|
85
|
+
}
|
|
86
|
+
/** The venue clock in ms, from the last subscription response or publication checkpoint's `server_time`. */
|
|
87
|
+
serverNow() {
|
|
88
|
+
return Date.now() + this.clockOffsetMs;
|
|
89
|
+
}
|
|
90
|
+
/** Resolves once the stream is live (starting it if needed); rejects if it closes for good. */
|
|
91
|
+
live(signal) {
|
|
92
|
+
this.start();
|
|
93
|
+
return this.until(() => this.status === "live" ? true : undefined, signal).then(() => undefined);
|
|
94
|
+
}
|
|
95
|
+
next(predicate, signal) {
|
|
96
|
+
this.start();
|
|
97
|
+
let found;
|
|
98
|
+
const off = this.on("event", (event) => {
|
|
99
|
+
if (found === undefined && predicate(event))
|
|
100
|
+
found = event;
|
|
101
|
+
});
|
|
102
|
+
return this.until(() => found, signal).finally(off);
|
|
103
|
+
}
|
|
104
|
+
until(check, signal) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const offs = [];
|
|
107
|
+
const done = (settle) => {
|
|
108
|
+
for (const off of offs)
|
|
109
|
+
off();
|
|
110
|
+
signal?.removeEventListener("abort", abort);
|
|
111
|
+
settle();
|
|
112
|
+
};
|
|
113
|
+
const test = () => {
|
|
114
|
+
const value = check();
|
|
115
|
+
if (value !== undefined)
|
|
116
|
+
done(() => resolve(value));
|
|
117
|
+
else if (this.status === "closed")
|
|
118
|
+
done(() => reject(new StreamClosedError("closed", 1000)));
|
|
119
|
+
};
|
|
120
|
+
const abort = () => done(() => reject(signal?.reason));
|
|
121
|
+
if (signal?.aborted)
|
|
122
|
+
return reject(signal.reason);
|
|
123
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
124
|
+
offs.push(this.on("status", test), this.on("event", test));
|
|
125
|
+
test();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
emit(type, ...args) {
|
|
129
|
+
for (const listener of [...this.listeners[type]]) {
|
|
130
|
+
try {
|
|
131
|
+
listener(...args);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (type !== "error")
|
|
135
|
+
this.emit("error", error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
setStatus(status) {
|
|
140
|
+
if (this.status === status || this.status === "closed")
|
|
141
|
+
return;
|
|
142
|
+
this.status = status;
|
|
143
|
+
this.emit("status", status);
|
|
144
|
+
}
|
|
145
|
+
reset() {
|
|
146
|
+
this.cursor = undefined;
|
|
147
|
+
this.seen.length = 0;
|
|
148
|
+
this.emit("reset");
|
|
149
|
+
}
|
|
150
|
+
apply(event, frame) {
|
|
151
|
+
const cursor = this.cursor;
|
|
152
|
+
if (cursor !== undefined && sameStream(cursor, event.cursor) && BigInt(event.cursor.sequence) <= BigInt(cursor.sequence))
|
|
153
|
+
return;
|
|
154
|
+
if (this.seen.includes(event.event_id))
|
|
155
|
+
return;
|
|
156
|
+
this.seen.push(event.event_id);
|
|
157
|
+
if (this.seen.length > 1024)
|
|
158
|
+
this.seen.shift();
|
|
159
|
+
this.cursor = event.cursor;
|
|
160
|
+
this.emit("event", event, frame);
|
|
161
|
+
}
|
|
162
|
+
freshness() {
|
|
163
|
+
const { type, publication_freshness = type === "account" } = this.options.subscription;
|
|
164
|
+
return publication_freshness;
|
|
165
|
+
}
|
|
166
|
+
async connect() {
|
|
167
|
+
this.replaying = false;
|
|
168
|
+
this.setStatus("connecting");
|
|
169
|
+
const { accessToken, environment = "production", baseUrl, streamBaseUrl } = this.options;
|
|
170
|
+
let socket;
|
|
171
|
+
try {
|
|
172
|
+
const token = typeof accessToken === "function" ? await accessToken() : accessToken;
|
|
173
|
+
if (this.status === "closed")
|
|
174
|
+
return;
|
|
175
|
+
const open = this.options.webSocket ?? openWebSocket;
|
|
176
|
+
const url = realtimeUrl(environment, baseUrl, streamBaseUrl);
|
|
177
|
+
socket = inBrowser()
|
|
178
|
+
? open(url, [realtimeProtocol, realtimeCredentialProtocol(token)], {})
|
|
179
|
+
: open(url, [realtimeProtocol], { ...this.options.headers, Authorization: `Bearer ${token}` });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
this.emit("error", error);
|
|
183
|
+
this.disconnected();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.socket = socket;
|
|
187
|
+
socket.onopen = () => socket.send(JSON.stringify(this.subscribe()));
|
|
188
|
+
socket.onmessage = (message) => {
|
|
189
|
+
if (socket !== this.socket)
|
|
190
|
+
return;
|
|
191
|
+
let frame;
|
|
192
|
+
try {
|
|
193
|
+
frame = JSON.parse(String(message.data));
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
this.emit("error", error);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (typeof frame !== "object" || frame === null || !("channel" in frame) || typeof frame.channel !== "string") {
|
|
200
|
+
this.emit("error", new TypeError("stream frame without a channel"));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.onFrame(frame);
|
|
204
|
+
};
|
|
205
|
+
socket.onclose = (event) => {
|
|
206
|
+
if (socket === this.socket)
|
|
207
|
+
this.onClose(event.code, event.reason);
|
|
208
|
+
};
|
|
209
|
+
this.watchSilence();
|
|
210
|
+
}
|
|
211
|
+
subscribe() {
|
|
212
|
+
const { publication_freshness = this.options.subscription.type === "account", ...subscription } = this.options.subscription;
|
|
213
|
+
return {
|
|
214
|
+
method: "subscribe",
|
|
215
|
+
subscription: { ...subscription, ...(publication_freshness ? { publication_freshness } : {}), ...(this.cursor === undefined ? {} : { cursor: this.cursor }) },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
onFrame(raw) {
|
|
219
|
+
this.watchSilence();
|
|
220
|
+
this.emit("frame", raw);
|
|
221
|
+
// The frames this client acts on, by their `channel`; any other channel is only emitted above.
|
|
222
|
+
const frame = raw;
|
|
223
|
+
switch (frame.channel) {
|
|
224
|
+
case "publication":
|
|
225
|
+
if (frame.data !== null)
|
|
226
|
+
this.clockOffsetMs = Date.parse(frame.data.server_time) - Date.now();
|
|
227
|
+
if (this.replaying) {
|
|
228
|
+
this.replaying = false;
|
|
229
|
+
this.goLive();
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
case "subscriptionResponse": {
|
|
233
|
+
const ready = frame.data;
|
|
234
|
+
this.clockOffsetMs = Date.parse(ready.server_time) - Date.now();
|
|
235
|
+
if (ready.resume === "REPLAY") {
|
|
236
|
+
this.cursor = ready.cursor;
|
|
237
|
+
// The replayed events follow this frame and nothing marks their end
|
|
238
|
+
// except the server's first publication checkpoint after them.
|
|
239
|
+
// Without publication freshness there is no such marker, so the
|
|
240
|
+
// stream is live once subscribed and replayed events may still follow.
|
|
241
|
+
if (this.freshness())
|
|
242
|
+
this.replaying = true;
|
|
243
|
+
else
|
|
244
|
+
this.goLive();
|
|
245
|
+
}
|
|
246
|
+
else if (ready.resume === "RESET" || this.cursor !== undefined) {
|
|
247
|
+
this.reset();
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case "snapshot": {
|
|
252
|
+
const snapshot = frame.data;
|
|
253
|
+
this.cursor = snapshot.cursor;
|
|
254
|
+
this.seen.length = 0;
|
|
255
|
+
this.emit("snapshot", snapshot);
|
|
256
|
+
this.goLive();
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case "event":
|
|
260
|
+
this.apply(frame.data, frame);
|
|
261
|
+
break;
|
|
262
|
+
case "reset":
|
|
263
|
+
this.reset();
|
|
264
|
+
break;
|
|
265
|
+
case "drain":
|
|
266
|
+
this.nextDelayMs = frame.data.retry_after_ms;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
goLive() {
|
|
271
|
+
this.attempt = 0;
|
|
272
|
+
this.reauthorized = false;
|
|
273
|
+
this.setStatus("live");
|
|
274
|
+
}
|
|
275
|
+
onClose(code, reason) {
|
|
276
|
+
this.socket = undefined;
|
|
277
|
+
clearTimeout(this.silence);
|
|
278
|
+
if (this.status === "closed")
|
|
279
|
+
return;
|
|
280
|
+
const retry = errorRegistry[reason]?.retry ?? "BACKOFF";
|
|
281
|
+
// A renewable session may retry a revoked credential once with a fresh token.
|
|
282
|
+
const renew = reason === "AUTHORIZATION_REVOKED" && typeof this.options.accessToken === "function" && !this.reauthorized;
|
|
283
|
+
if (retry === "NEVER" && !renew) {
|
|
284
|
+
this.close();
|
|
285
|
+
this.emit("error", new StreamClosedError(reason, code));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (renew)
|
|
289
|
+
this.reauthorized = true;
|
|
290
|
+
if (retry === "REFRESH" && this.cursor !== undefined)
|
|
291
|
+
this.reset();
|
|
292
|
+
this.disconnected();
|
|
293
|
+
}
|
|
294
|
+
disconnected() {
|
|
295
|
+
if (this.status === "closed")
|
|
296
|
+
return;
|
|
297
|
+
this.setStatus("disconnected");
|
|
298
|
+
const { baseDelayMs = 500, maxDelayMs = 30_000 } = this.options.reconnect ?? {};
|
|
299
|
+
// A drain's hint is the same for every socket it closes: wait at least that long, spread over as long again.
|
|
300
|
+
const delay = this.nextDelayMs === undefined ? backoffDelay(this.attempt, baseDelayMs, maxDelayMs) : this.nextDelayMs * (1 + Math.random());
|
|
301
|
+
this.nextDelayMs = undefined;
|
|
302
|
+
this.attempt++;
|
|
303
|
+
clearTimeout(this.timer);
|
|
304
|
+
this.timer = setTimeout(() => void this.connect(), delay);
|
|
305
|
+
}
|
|
306
|
+
watchSilence() {
|
|
307
|
+
const { silenceTimeoutMs = this.freshness() ? 5000 : undefined } = this.options;
|
|
308
|
+
clearTimeout(this.silence);
|
|
309
|
+
if (silenceTimeoutMs === undefined)
|
|
310
|
+
return;
|
|
311
|
+
this.silence = setTimeout(() => {
|
|
312
|
+
const socket = this.socket;
|
|
313
|
+
this.socket = undefined;
|
|
314
|
+
socket?.close(4000, "silent");
|
|
315
|
+
this.disconnected();
|
|
316
|
+
}, silenceTimeoutMs);
|
|
317
|
+
}
|
|
318
|
+
}
|