@leavepulse/control-sdk 0.3.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/auth-types.ts +5296 -0
  3. package/client.ts +320 -0
  4. package/index.ts +110 -0
  5. package/models.ts +232 -0
  6. package/package.json +28 -0
  7. package/procedures.ts +451 -0
  8. package/resources/ControlAgentRelease.ts +40 -0
  9. package/resources/ControlAlert.ts +39 -0
  10. package/resources/ControlCfAccount.ts +82 -0
  11. package/resources/ControlDcimAcceptance.ts +126 -0
  12. package/resources/ControlDcimCable.ts +70 -0
  13. package/resources/ControlDcimComponent.ts +62 -0
  14. package/resources/ControlDcimDevice.ts +71 -0
  15. package/resources/ControlDcimFeed.ts +61 -0
  16. package/resources/ControlDcimLocation.ts +55 -0
  17. package/resources/ControlDcimOutlet.ts +61 -0
  18. package/resources/ControlDcimPdu.ts +58 -0
  19. package/resources/ControlDcimPort.ts +61 -0
  20. package/resources/ControlDcimPowerLink.ts +34 -0
  21. package/resources/ControlDcimRack.ts +61 -0
  22. package/resources/ControlEdge.ts +43 -0
  23. package/resources/ControlEnrollToken.ts +45 -0
  24. package/resources/ControlEnvGroup.ts +60 -0
  25. package/resources/ControlHost.ts +184 -0
  26. package/resources/ControlNode.ts +48 -0
  27. package/resources/ControlProject.ts +36 -0
  28. package/resources/ControlRule.ts +56 -0
  29. package/resources/ControlSchedule.ts +56 -0
  30. package/resources/ControlService.ts +101 -0
  31. package/runtime/cache-policy.ts +371 -0
  32. package/runtime/cache.ts +129 -0
  33. package/runtime/credentials.ts +139 -0
  34. package/runtime/device.ts +199 -0
  35. package/runtime/errors.ts +225 -0
  36. package/runtime/etag-store.ts +252 -0
  37. package/runtime/json.ts +25 -0
  38. package/runtime/oauth2.ts +150 -0
  39. package/runtime/page.ts +81 -0
  40. package/runtime/realtime-client.ts +339 -0
  41. package/runtime/realtime.ts +257 -0
  42. package/runtime/realtime_pb/leavepulse/realtime/v1/ws_pb.ts +464 -0
  43. package/runtime/resource.ts +84 -0
  44. package/runtime/snowflake.ts +7 -0
  45. package/runtime/transport.ts +404 -0
  46. package/types.ts +7279 -0
@@ -0,0 +1,150 @@
1
+ // LeavePulse SDK — OAuth2 authorization-code + PKCE facade for 3rd-party web
2
+ // apps acting on behalf of a LeavePulse *user*.
3
+ //
4
+ // `buildAuthorizeUrl` is a pure helper: it mints a PKCE S256 pair and assembles
5
+ // the authorize URL. The app sends the user to that URL; a **frontend** page
6
+ // renders the visual consent (the SDK never drives a browser). After the
7
+ // redirect-back with `?code=`, `exchangeCode` trades the code for tokens at
8
+ // `/auth/oauth/token` (form-urlencoded) and returns an auto-refreshing
9
+ // `OAuth2Credential`. The exchange is driven through the caller's transport,
10
+ // so it works without the generated `auth.oauth2.*` methods existing yet.
11
+
12
+ import { OAuth2Credential } from "./credentials";
13
+ import type { Transport } from "./transport";
14
+
15
+ /** Inputs for {@link buildAuthorizeUrl}. */
16
+ export interface BuildAuthorizeUrlInit {
17
+ /** OAuth2 client id of the third-party app. */
18
+ clientId: string;
19
+ /** Redirect URI registered for the app (must match at exchange). */
20
+ redirectUri: string;
21
+ /** Requested scopes; joined with spaces per OAuth2. */
22
+ scope: string[];
23
+ /** Base URL of the authorize page (a frontend URL). */
24
+ authorizeBaseUrl: string;
25
+ /** CSRF/anti-forgery state; a random one is generated when omitted. */
26
+ state?: string;
27
+ }
28
+
29
+ /** Result of {@link buildAuthorizeUrl}: the URL to send the user to, plus the
30
+ * PKCE verifier and state the app must keep until {@link exchangeCode}. */
31
+ export interface AuthorizeUrl {
32
+ /** The authorize URL to open in the frontend (visual consent). */
33
+ url: string;
34
+ /** PKCE `code_verifier` — keep secret, pass back to `exchangeCode`. */
35
+ codeVerifier: string;
36
+ /** The `state` echoed back on redirect — verify it matches. */
37
+ state: string;
38
+ }
39
+
40
+ /**
41
+ * Build an OAuth2 authorize URL with a fresh PKCE (S256) challenge. Pure (no
42
+ * network); runs anywhere Web Crypto is available. `response_type=code`,
43
+ * `code_challenge_method=S256`. Async because the S256 digest is async.
44
+ */
45
+ export async function buildAuthorizeUrl(
46
+ init: BuildAuthorizeUrlInit,
47
+ ): Promise<AuthorizeUrl> {
48
+ const codeVerifier = randomUrlSafe(64);
49
+ const state = init.state ?? randomUrlSafe(32);
50
+ const codeChallenge = await s256Challenge(codeVerifier);
51
+
52
+ const params = new URLSearchParams({
53
+ response_type: "code",
54
+ client_id: init.clientId,
55
+ redirect_uri: init.redirectUri,
56
+ scope: init.scope.join(" "),
57
+ state,
58
+ code_challenge: codeChallenge,
59
+ code_challenge_method: "S256",
60
+ });
61
+ const base = init.authorizeBaseUrl.replace(/\/$/, "");
62
+ return { url: `${base}?${params.toString()}`, codeVerifier, state };
63
+ }
64
+
65
+ /** Inputs for {@link exchangeCode}. */
66
+ export interface ExchangeCodeInit {
67
+ clientId: string;
68
+ /** Authorization code returned to the redirect URI. */
69
+ code: string;
70
+ /** Same redirect URI used to build the authorize URL. */
71
+ redirectUri: string;
72
+ /** The PKCE `code_verifier` from {@link buildAuthorizeUrl}. */
73
+ codeVerifier: string;
74
+ }
75
+
76
+ /** `/auth/oauth/token` response shape (wire snake_case). */
77
+ interface TokenExchangeResponse {
78
+ access_token: string;
79
+ refresh_token?: string | null;
80
+ expires_in?: number | null;
81
+ token_type?: string | null;
82
+ }
83
+
84
+ /**
85
+ * Exchange an authorization code for tokens at `/auth/oauth/token`
86
+ * (form-urlencoded, `grant_type=authorization_code`) and return an
87
+ * auto-refreshing {@link OAuth2Credential}. Driven through the supplied
88
+ * `transport` (channel `auth`), so it does not depend on generated code.
89
+ */
90
+ export async function exchangeCode(
91
+ init: ExchangeCodeInit,
92
+ transport: Transport,
93
+ ): Promise<OAuth2Credential> {
94
+ const tokens = await postToken(transport, {
95
+ grant_type: "authorization_code",
96
+ code: init.code,
97
+ client_id: init.clientId,
98
+ redirect_uri: init.redirectUri,
99
+ code_verifier: init.codeVerifier,
100
+ });
101
+ return new OAuth2Credential({
102
+ accessToken: tokens.access_token,
103
+ refreshToken: tokens.refresh_token ?? "",
104
+ expiresIn: tokens.expires_in,
105
+ // Auto-refresh via the same /token endpoint (refresh_token grant).
106
+ refreshFn: (refreshToken) =>
107
+ postToken(transport, {
108
+ grant_type: "refresh_token",
109
+ refresh_token: refreshToken,
110
+ client_id: init.clientId,
111
+ }),
112
+ });
113
+ }
114
+
115
+ /** POST a form-encoded grant request to `/auth/oauth/token` on the auth channel. */
116
+ function postToken(
117
+ transport: Transport,
118
+ form: Record<string, string>,
119
+ ): Promise<TokenExchangeResponse> {
120
+ return transport.request<TokenExchangeResponse>({
121
+ method: "POST",
122
+ path: "/auth/oauth/token",
123
+ channel: "auth",
124
+ form,
125
+ });
126
+ }
127
+
128
+ /** Base64url-encode bytes (no padding), per RFC 7636. */
129
+ function base64UrlEncode(bytes: Uint8Array): string {
130
+ let binary = "";
131
+ for (const byte of bytes) binary += String.fromCharCode(byte);
132
+ return btoa(binary)
133
+ .replace(/\+/g, "-")
134
+ .replace(/\//g, "_")
135
+ .replace(/=+$/, "");
136
+ }
137
+
138
+ /** A cryptographically-random URL-safe string of `byteLength` entropy. */
139
+ function randomUrlSafe(byteLength: number): string {
140
+ const bytes = new Uint8Array(byteLength);
141
+ crypto.getRandomValues(bytes);
142
+ return base64UrlEncode(bytes);
143
+ }
144
+
145
+ /** Compute the PKCE S256 `code_challenge` from a verifier. */
146
+ async function s256Challenge(verifier: string): Promise<string> {
147
+ const data = new TextEncoder().encode(verifier);
148
+ const digest = await crypto.subtle.digest("SHA-256", data);
149
+ return base64UrlEncode(new Uint8Array(digest));
150
+ }
@@ -0,0 +1,81 @@
1
+ // LeavePulse SDK — pagination.
2
+ //
3
+ // `list`-style operations (x-sdk-paginated) return a Page<T> that is both a
4
+ // snapshot of one page and an async iterator over all pages. Callers can do
5
+ // `for await (const item of client.project.list()) { ... }` without juggling
6
+ // page/limit by hand.
7
+
8
+ export interface PageData<T> {
9
+ items: T[];
10
+ page: number;
11
+ per_page: number;
12
+ total: number;
13
+ }
14
+
15
+ export type PageFetcher<T> = (
16
+ page: number,
17
+ perPage: number,
18
+ ) => Promise<PageData<T>>;
19
+
20
+ /** Coerce an arbitrary list-envelope body into `PageData<T>`, hydrating items
21
+ * via the supplied callback and reading the canonical `{total,page,per_page}`
22
+ * fields (falling back sensibly when a field is absent). Generated list methods
23
+ * build the per-endpoint `hydrate` closure; the field plumbing lives here so it
24
+ * stays identical across every paginated method. */
25
+ export function pageDataFrom<T>(
26
+ body: unknown,
27
+ hydrate: (items: unknown[]) => T[],
28
+ requestedPage: number,
29
+ requestedPerPage: number,
30
+ ): PageData<T> {
31
+ const obj = (body ?? {}) as Record<string, unknown>;
32
+ const rawItems = Array.isArray(obj.items) ? (obj.items as unknown[]) : [];
33
+ const items = hydrate(rawItems);
34
+ const num = (v: unknown, fallback: number): number => {
35
+ const n = Number(v);
36
+ return Number.isFinite(n) ? n : fallback;
37
+ };
38
+ return {
39
+ items,
40
+ total: num(obj.total, items.length),
41
+ page: num(obj.page, requestedPage),
42
+ per_page: num(obj.per_page, requestedPerPage),
43
+ };
44
+ }
45
+
46
+ export class Page<T> implements AsyncIterable<T> {
47
+ readonly items: T[];
48
+ readonly page: number;
49
+ readonly perPage: number;
50
+ readonly total: number;
51
+
52
+ constructor(
53
+ data: PageData<T>,
54
+ private readonly fetcher: PageFetcher<T>,
55
+ ) {
56
+ this.items = data.items;
57
+ this.page = data.page;
58
+ this.perPage = data.per_page;
59
+ this.total = data.total;
60
+ }
61
+
62
+ get hasNext(): boolean {
63
+ return this.page * this.perPage < this.total;
64
+ }
65
+
66
+ async next(): Promise<Page<T> | null> {
67
+ if (!this.hasNext) return null;
68
+ const data = await this.fetcher(this.page + 1, this.perPage);
69
+ return new Page(data, this.fetcher);
70
+ }
71
+
72
+ /** Iterate every item across all pages, fetching lazily. */
73
+ async *[Symbol.asyncIterator](): AsyncIterator<T> {
74
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
75
+ let current: Page<T> | null = this;
76
+ while (current) {
77
+ for (const item of current.items) yield item;
78
+ current = await current.next();
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,339 @@
1
+ // LeavePulse SDK — bidirectional realtime client.
2
+ //
3
+ // THE single WS contract between a client and the system. One multiplexed
4
+ // socket carries every topic subscription AND bidirectional exchanges:
5
+ // - fan-out: server → client `event` frames for subscribed topics
6
+ // - request/reply: client → server `request`, server → client `reply`
7
+ // - session input: client → server `input` (for future console/exec)
8
+ // The wire-format is protobuf (ws.proto: ClientFrame / ServerFrame), so the
9
+ // contract evolves by field tags without rewrites and is shared by every client
10
+ // (browser, node) and the gateway. This client owns connect/auth/reconnect; Vue
11
+ // or other UI layers wrap it, they never touch the socket.
12
+
13
+ import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
14
+ import {
15
+ ClientFrameSchema,
16
+ type ServerFrame,
17
+ ServerFrameSchema,
18
+ } from "./realtime_pb/leavepulse/realtime/v1/ws_pb.js";
19
+
20
+ /** Binary WebSocket shape both the browser and the `ws` package satisfy. */
21
+ export interface BinaryWebSocketLike {
22
+ send(data: Uint8Array): void;
23
+ close(): void;
24
+ binaryType: string;
25
+ addEventListener(type: "open" | "close" | "error", cb: () => void): void;
26
+ addEventListener(type: "message", cb: (ev: { data: unknown }) => void): void;
27
+ }
28
+
29
+ export type RealtimeEventKind = "initial" | "update" | "error";
30
+
31
+ export interface RealtimeEnvelope<T = unknown> {
32
+ topic: string;
33
+ data: T;
34
+ }
35
+
36
+ export type RealtimeHandler<T = unknown> = (env: RealtimeEnvelope<T>) => void;
37
+
38
+ export interface RealtimeClientOptions {
39
+ /** Build a socket for the given URL (browser `WebSocket`, or `ws`). */
40
+ socketFactory: (url: string) => BinaryWebSocketLike;
41
+ /** Base WS URL, e.g. `wss://rt.leavepulse.com`. */
42
+ url: string;
43
+ /** Async provider for the realtime auth (ws-)token; re-invoked on reconnect. */
44
+ getToken?: () => Promise<string | null> | string | null;
45
+ /** Reconnect backoff bounds (ms). */
46
+ reconnectMinMs?: number;
47
+ reconnectMaxMs?: number;
48
+ }
49
+
50
+ interface Subscription {
51
+ topic: string;
52
+ handlers: { kind: RealtimeEventKind; fn: RealtimeHandler }[];
53
+ bootstrapped: boolean;
54
+ }
55
+
56
+ interface PendingRequest {
57
+ resolve: (payload: Uint8Array) => void;
58
+ reject: (err: Error) => void;
59
+ }
60
+
61
+ const DEFAULT_MIN_MS = 500;
62
+ const DEFAULT_MAX_MS = 30_000;
63
+
64
+ /** Decode a JSON payload carried in an Event/Reply `data` byte field. */
65
+ function decodeJson(bytes: Uint8Array): unknown {
66
+ if (bytes.length === 0) return null;
67
+ try {
68
+ return JSON.parse(new TextDecoder().decode(bytes));
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function encodeJson(value: unknown): Uint8Array {
75
+ return new TextEncoder().encode(JSON.stringify(value ?? null));
76
+ }
77
+
78
+ /**
79
+ * Owns the single multiplexed protobuf WebSocket. Reconnects with backoff,
80
+ * re-authenticates and re-subscribes on reconnect, and routes events / replies.
81
+ */
82
+ export class RealtimeClient {
83
+ private socket: BinaryWebSocketLike | null = null;
84
+ private connecting: Promise<void> | null = null;
85
+ private authenticated = false;
86
+ private closedByUser = false;
87
+ private reconnectAttempts = 0;
88
+ private requestSeq = 0;
89
+ private readonly subscriptions = new Map<string, Subscription>();
90
+ private readonly pending = new Map<string, PendingRequest>();
91
+ // Resolved when the server's `welcome` frame lands — i.e. auth has been
92
+ // processed. Subscribes wait on this so a private topic is never requested
93
+ // before the gateway has bound the socket's identity (otherwise it replies
94
+ // "Authentication required for private topic" and we needlessly reconnect).
95
+ private welcomed: Promise<void> | null = null;
96
+ private resolveWelcome: (() => void) | null = null;
97
+
98
+ constructor(private readonly opts: RealtimeClientOptions) {}
99
+
100
+ get isAuthenticated(): boolean {
101
+ return this.authenticated;
102
+ }
103
+
104
+ /** Subscribe to a topic; returns an unsubscribe function. */
105
+ async subscribe(
106
+ topic: string,
107
+ kind: RealtimeEventKind,
108
+ handler: RealtimeHandler,
109
+ ): Promise<() => void> {
110
+ await this.ensureConnected();
111
+ let sub = this.subscriptions.get(topic);
112
+ if (!sub) {
113
+ sub = { topic, handlers: [], bootstrapped: false };
114
+ this.subscriptions.set(topic, sub);
115
+ this.sendSubscribe(topic);
116
+ }
117
+ sub.handlers.push({ kind, fn: handler });
118
+ return () => this.removeHandler(topic, handler);
119
+ }
120
+
121
+ /** Send a request and await its single reply (the JSON-decoded payload). */
122
+ async request<T = unknown>(method: string, payload?: unknown): Promise<T> {
123
+ await this.ensureConnected();
124
+ const id = `r${++this.requestSeq}`;
125
+ const frame = create(ClientFrameSchema, {
126
+ body: {
127
+ case: "request",
128
+ value: { id, method, payload: encodeJson(payload) },
129
+ },
130
+ });
131
+ const result = new Promise<Uint8Array>((resolve, reject) => {
132
+ this.pending.set(id, { resolve, reject });
133
+ });
134
+ this.sendFrame(frame);
135
+ const bytes = await result;
136
+ return decodeJson(bytes) as T;
137
+ }
138
+
139
+ /** Send a chunk of session input (e.g. console stdin) for a session. */
140
+ async sendInput(sessionId: string, data: Uint8Array): Promise<void> {
141
+ await this.ensureConnected();
142
+ this.sendFrame(
143
+ create(ClientFrameSchema, {
144
+ body: { case: "input", value: { sessionId, data } },
145
+ }),
146
+ );
147
+ }
148
+
149
+ close(): void {
150
+ this.closedByUser = true;
151
+ this.subscriptions.clear();
152
+ for (const p of this.pending.values())
153
+ p.reject(new Error("realtime client closed"));
154
+ this.pending.clear();
155
+ this.socket?.close();
156
+ this.socket = null;
157
+ this.connecting = null;
158
+ this.authenticated = false;
159
+ this.resolveWelcome?.();
160
+ this.resolveWelcome = null;
161
+ this.welcomed = null;
162
+ }
163
+
164
+ // ── internals ────────────────────────────────────────────────────────────
165
+
166
+ private removeHandler(topic: string, handler: RealtimeHandler): void {
167
+ const sub = this.subscriptions.get(topic);
168
+ if (!sub) return;
169
+ sub.handlers = sub.handlers.filter((h) => h.fn !== handler);
170
+ if (sub.handlers.length === 0) {
171
+ this.subscriptions.delete(topic);
172
+ this.sendFrame(
173
+ create(ClientFrameSchema, {
174
+ body: { case: "unsubscribe", value: { topic } },
175
+ }),
176
+ );
177
+ }
178
+ }
179
+
180
+ private sendSubscribe(topic: string): void {
181
+ this.sendFrame(
182
+ create(ClientFrameSchema, {
183
+ body: { case: "subscribe", value: { topic } },
184
+ }),
185
+ );
186
+ }
187
+
188
+ private async ensureConnected(): Promise<void> {
189
+ if (this.socket && this.authenticated) return;
190
+ if (this.connecting) return this.connecting;
191
+ this.connecting = this.connect();
192
+ return this.connecting;
193
+ }
194
+
195
+ private async connect(): Promise<void> {
196
+ this.closedByUser = false;
197
+ const token = this.opts.getToken ? await this.opts.getToken() : null;
198
+ const socket = this.opts.socketFactory(this.opts.url);
199
+ socket.binaryType = "arraybuffer";
200
+ this.socket = socket;
201
+
202
+ // Arm the welcome gate before sending auth, so the frame can't race us.
203
+ this.welcomed = new Promise<void>((resolve) => {
204
+ this.resolveWelcome = resolve;
205
+ });
206
+
207
+ await new Promise<void>((resolve, reject) => {
208
+ socket.addEventListener("open", () => resolve());
209
+ socket.addEventListener("error", () =>
210
+ reject(new Error("realtime socket error")),
211
+ );
212
+ });
213
+ socket.addEventListener("message", (ev) => this.onMessage(ev.data));
214
+ socket.addEventListener("close", () => this.onClose());
215
+
216
+ // Authenticate first so private subscriptions are accepted.
217
+ if (token) {
218
+ this.sendFrame(
219
+ create(ClientFrameSchema, {
220
+ body: { case: "auth", value: { token } },
221
+ }),
222
+ );
223
+ }
224
+ // Wait for the gateway's `welcome` (auth applied) before (re)subscribing,
225
+ // so private topics aren't requested on an un-bound socket. The gate is
226
+ // resolved in onMessage; if the socket drops first, onClose rejects the
227
+ // retry path, so a hung await can't wedge us.
228
+ await this.welcomed;
229
+ // Re-subscribe everything (covers reconnect).
230
+ for (const topic of this.subscriptions.keys()) this.sendSubscribe(topic);
231
+ this.reconnectAttempts = 0;
232
+ }
233
+
234
+ private onClose(): void {
235
+ this.socket = null;
236
+ this.connecting = null;
237
+ this.authenticated = false;
238
+ // Unblock a connect() still awaiting `welcome` so its promise can settle
239
+ // (it'll re-arm on the next attempt); reconnect drives the retry.
240
+ this.resolveWelcome?.();
241
+ this.resolveWelcome = null;
242
+ if (this.closedByUser) return;
243
+ this.scheduleReconnect();
244
+ }
245
+
246
+ private scheduleReconnect(): void {
247
+ const min = this.opts.reconnectMinMs ?? DEFAULT_MIN_MS;
248
+ const max = this.opts.reconnectMaxMs ?? DEFAULT_MAX_MS;
249
+ const delay = Math.min(max, min * 2 ** this.reconnectAttempts++);
250
+ setTimeout(() => {
251
+ if (this.closedByUser) return;
252
+ void this.ensureConnected().catch(() => this.scheduleReconnect());
253
+ }, delay);
254
+ }
255
+
256
+ private onMessage(raw: unknown): void {
257
+ const bytes = this.toBytes(raw);
258
+ if (!bytes) return;
259
+ let frame: ServerFrame;
260
+ try {
261
+ frame = fromBinary(ServerFrameSchema, bytes);
262
+ } catch {
263
+ return;
264
+ }
265
+ switch (frame.body.case) {
266
+ case "ping":
267
+ this.sendFrame(
268
+ create(ClientFrameSchema, { body: { case: "ping", value: {} } }),
269
+ );
270
+ return;
271
+ case "welcome":
272
+ this.authenticated = frame.body.value.authenticated;
273
+ // Release any subscribes waiting on the auth handshake.
274
+ this.resolveWelcome?.();
275
+ this.resolveWelcome = null;
276
+ return;
277
+ case "event": {
278
+ const ev = frame.body.value;
279
+ this.dispatchEvent(ev.topic, decodeJson(ev.data));
280
+ return;
281
+ }
282
+ case "reply": {
283
+ const reply = frame.body.value;
284
+ const pending = this.pending.get(reply.id);
285
+ if (!pending) return;
286
+ this.pending.delete(reply.id);
287
+ if (reply.error)
288
+ pending.reject(
289
+ new Error(
290
+ reply.error.message || reply.error.code || "request failed",
291
+ ),
292
+ );
293
+ else pending.resolve(reply.payload);
294
+ return;
295
+ }
296
+ case "error": {
297
+ const err = frame.body.value;
298
+ if (err.id && this.pending.has(err.id)) {
299
+ const p = this.pending.get(err.id);
300
+ this.pending.delete(err.id);
301
+ p?.reject(new Error(err.message || err.code || "realtime error"));
302
+ return;
303
+ }
304
+ if (err.topic) this.dispatchError(err.topic, err);
305
+ return;
306
+ }
307
+ default:
308
+ return;
309
+ }
310
+ }
311
+
312
+ private toBytes(raw: unknown): Uint8Array | null {
313
+ if (raw instanceof Uint8Array) return raw;
314
+ if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
315
+ return null;
316
+ }
317
+
318
+ private dispatchEvent(topic: string, data: unknown): void {
319
+ const sub = this.subscriptions.get(topic);
320
+ if (!sub) return;
321
+ const kind: RealtimeEventKind = sub.bootstrapped ? "update" : "initial";
322
+ sub.bootstrapped = true;
323
+ for (const h of sub.handlers) if (h.kind === kind) h.fn({ topic, data });
324
+ }
325
+
326
+ private dispatchError(topic: string, error: unknown): void {
327
+ const sub = this.subscriptions.get(topic);
328
+ if (!sub) return;
329
+ for (const h of sub.handlers)
330
+ if (h.kind === "error") h.fn({ topic, data: error });
331
+ }
332
+
333
+ private sendFrame(
334
+ frame: ReturnType<typeof create<typeof ClientFrameSchema>>,
335
+ ): void {
336
+ if (!this.socket) return;
337
+ this.socket.send(toBinary(ClientFrameSchema, frame));
338
+ }
339
+ }