@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,257 @@
1
+ // LeavePulse SDK — realtime layer.
2
+ //
3
+ // A single WebSocket multiplexes every topic subscription. The transport is
4
+ // pluggable: the browser passes a WebSocket factory + token provider, external
5
+ // consumers pass a `ws`-package factory + bearer token. The SDK never knows how
6
+ // the socket is created — it only speaks the gateway's JSON protocol:
7
+ // client → server: {op: "subscribe"|"unsubscribe"|"auth"|"ping", topic?, token?}
8
+ // server → client: {type: "welcome"|"subscribed"|"event"|"error"|"ping"|...}
9
+ // On subscribe the gateway sends a bootstrap snapshot first (the SDK surfaces
10
+ // it as the `initial` event), then deltas as `update`.
11
+
12
+ export type RealtimeEvent = "initial" | "update" | "error";
13
+
14
+ /** A normalized realtime error, shaped like an HTTP `ProblemDetails` so error
15
+ * handling is consistent across channels. The gateway may send the error as a
16
+ * JSON-RPC object (`{code, message, data: {error_code, request_id, …}}`), a
17
+ * flat `{code, message, request_id}`, or an RFC 7807 body — all collapse here. */
18
+ export interface RealtimeError {
19
+ /** Stable machine-readable error code (e.g. `SESSION_EXPIRED`). */
20
+ code?: string;
21
+ /** Human-readable message. */
22
+ message: string;
23
+ /** Correlation id for support, when present. */
24
+ requestId?: string;
25
+ /** Extra structured context. */
26
+ details?: Record<string, unknown>;
27
+ /** The raw error payload as received, for escape-hatch inspection. */
28
+ raw: unknown;
29
+ }
30
+
31
+ /** Normalize any realtime error payload into a {@link RealtimeError}. */
32
+ export function toRealtimeError(raw: unknown): RealtimeError {
33
+ if (raw == null || typeof raw !== "object") {
34
+ return { message: String(raw ?? "realtime error"), raw };
35
+ }
36
+ const obj = raw as Record<string, unknown>;
37
+ // JSON-RPC error object: { code, message, data: {...} }
38
+ const data = (obj.data ?? {}) as Record<string, unknown>;
39
+ const code =
40
+ (obj.error_code as string) ??
41
+ (data.error_code as string) ??
42
+ (typeof obj.code === "string" ? (obj.code as string) : undefined);
43
+ const message = String(
44
+ obj.message ?? obj.detail ?? obj.title ?? "realtime error",
45
+ );
46
+ const requestId =
47
+ (obj.request_id as string) ??
48
+ (obj.requestId as string) ??
49
+ (data.request_id as string) ??
50
+ (data.requestId as string);
51
+ const details =
52
+ (data.details as Record<string, unknown>) ??
53
+ (obj.details as Record<string, unknown>) ??
54
+ undefined;
55
+ return { code, message, requestId, details, raw };
56
+ }
57
+
58
+ export interface RealtimeMessage<T = unknown> {
59
+ topic: string;
60
+ data: T;
61
+ }
62
+
63
+ export type RealtimeHandler<T = unknown> = (msg: RealtimeMessage<T>) => void;
64
+
65
+ /** Minimal WebSocket shape both the browser and the `ws` package satisfy. */
66
+ export interface WebSocketLike {
67
+ send(data: string): void;
68
+ close(): void;
69
+ addEventListener(type: "open" | "close" | "error", cb: () => void): void;
70
+ addEventListener(type: "message", cb: (ev: { data: unknown }) => void): void;
71
+ }
72
+
73
+ export interface RealtimeTransportOptions {
74
+ /** Build a socket for the given URL (browser `WebSocket`, or `ws`). */
75
+ socketFactory: (url: string) => WebSocketLike;
76
+ /** Base WS URL, e.g. `wss://realtime.leavepulse.com`. */
77
+ url: string;
78
+ /** Optional async token provider for authenticating private topics. */
79
+ getToken?: () => Promise<string | null> | string | null;
80
+ }
81
+
82
+ interface Subscription {
83
+ topic: string;
84
+ handlers: { event: RealtimeEvent; fn: RealtimeHandler }[];
85
+ /** Whether the bootstrap snapshot has already been delivered. */
86
+ bootstrapped: boolean;
87
+ }
88
+
89
+ /**
90
+ * Owns the single multiplexed socket and dispatches incoming events to the
91
+ * per-topic subscriptions. Resource classes call `subscribe(topic, ...)`; they
92
+ * never touch the socket directly.
93
+ */
94
+ export class RealtimeTransport {
95
+ private socket: WebSocketLike | null = null;
96
+ private connecting: Promise<void> | null = null;
97
+ private authenticated = false;
98
+ private readonly subscriptions = new Map<string, Subscription>();
99
+
100
+ constructor(private readonly opts: RealtimeTransportOptions) {}
101
+
102
+ /** Subscribe to a topic; returns an unsubscribe function. */
103
+ async subscribe(
104
+ topic: string,
105
+ event: RealtimeEvent,
106
+ handler: RealtimeHandler,
107
+ ): Promise<() => void> {
108
+ await this.ensureConnected();
109
+ let sub = this.subscriptions.get(topic);
110
+ if (!sub) {
111
+ sub = { topic, handlers: [], bootstrapped: false };
112
+ this.subscriptions.set(topic, sub);
113
+ this.send({ op: "subscribe", topic });
114
+ }
115
+ sub.handlers.push({ event, fn: handler });
116
+
117
+ return () => this.removeHandler(topic, handler);
118
+ }
119
+
120
+ private removeHandler(topic: string, handler: RealtimeHandler): void {
121
+ const sub = this.subscriptions.get(topic);
122
+ if (!sub) return;
123
+ sub.handlers = sub.handlers.filter((h) => h.fn !== handler);
124
+ if (sub.handlers.length === 0) {
125
+ this.subscriptions.delete(topic);
126
+ this.send({ op: "unsubscribe", topic });
127
+ }
128
+ }
129
+
130
+ /** Whether the socket has completed the realtime auth handshake. */
131
+ get isAuthenticated(): boolean {
132
+ return this.authenticated;
133
+ }
134
+
135
+ /** Close the socket and drop all subscriptions. */
136
+ close(): void {
137
+ this.subscriptions.clear();
138
+ this.socket?.close();
139
+ this.socket = null;
140
+ this.connecting = null;
141
+ this.authenticated = false;
142
+ }
143
+
144
+ private async ensureConnected(): Promise<void> {
145
+ if (this.socket) return;
146
+ if (this.connecting) return this.connecting;
147
+ this.connecting = this.connect();
148
+ return this.connecting;
149
+ }
150
+
151
+ private async connect(): Promise<void> {
152
+ const token = this.opts.getToken ? await this.opts.getToken() : null;
153
+ const url = token
154
+ ? `${this.opts.url}?token=${encodeURIComponent(token)}`
155
+ : this.opts.url;
156
+ const socket = this.opts.socketFactory(url);
157
+ this.socket = socket;
158
+
159
+ await new Promise<void>((resolve, reject) => {
160
+ socket.addEventListener("open", () => resolve());
161
+ socket.addEventListener("error", () =>
162
+ reject(new Error("realtime socket error")),
163
+ );
164
+ });
165
+ socket.addEventListener("message", (ev) => this.onMessage(ev.data));
166
+ socket.addEventListener("close", () => {
167
+ this.socket = null;
168
+ this.connecting = null;
169
+ this.authenticated = false;
170
+ });
171
+ }
172
+
173
+ private onMessage(raw: unknown): void {
174
+ let frame: Record<string, unknown>;
175
+ try {
176
+ frame = JSON.parse(String(raw));
177
+ } catch {
178
+ return;
179
+ }
180
+ const type = frame.type;
181
+ if (type === "ping") {
182
+ this.send({ op: "pong" });
183
+ return;
184
+ }
185
+ if (type === "welcome" || type === "authenticated") {
186
+ this.authenticated =
187
+ Boolean(frame.authenticated) || type === "authenticated";
188
+ return;
189
+ }
190
+ if (type === "event") {
191
+ this.dispatchEvent(String(frame.topic ?? ""), frame.data);
192
+ return;
193
+ }
194
+ if (type === "error") {
195
+ this.dispatchError(frame.topic, frame.error);
196
+ }
197
+ }
198
+
199
+ private dispatchEvent(topic: string, data: unknown): void {
200
+ const sub = this.subscriptions.get(topic);
201
+ if (!sub) return;
202
+ // First event on a topic is the bootstrap snapshot → `initial`.
203
+ const event: RealtimeEvent = sub.bootstrapped ? "update" : "initial";
204
+ sub.bootstrapped = true;
205
+ for (const h of sub.handlers) {
206
+ if (h.event === event) h.fn({ topic, data });
207
+ }
208
+ }
209
+
210
+ private dispatchError(topic: unknown, error: unknown): void {
211
+ if (typeof topic !== "string") return;
212
+ const sub = this.subscriptions.get(topic);
213
+ if (!sub) return;
214
+ // Normalize the wire error so handlers get a consistent RealtimeError
215
+ // instead of a raw, per-gateway-shape `unknown`.
216
+ const normalized = toRealtimeError(error);
217
+ for (const h of sub.handlers) {
218
+ if (h.event === "error") h.fn({ topic, data: normalized });
219
+ }
220
+ }
221
+
222
+ private send(payload: Record<string, unknown>): void {
223
+ this.socket?.send(JSON.stringify(payload));
224
+ }
225
+ }
226
+
227
+ /**
228
+ * A handle returned by `resource.subscribe()`. Register handlers with `.on(...)`
229
+ * and tear down with `.close()`.
230
+ *
231
+ * `TPayload` is the topic's event payload type: a generated `resource.onX()`
232
+ * method returns `TopicSubscription<PayloadType>` so `.on("update", …)` receives
233
+ * a typed message. It defaults to `unknown` for topics with no declared payload.
234
+ */
235
+ export class TopicSubscription<TPayload = unknown> {
236
+ private readonly disposers: Array<() => void> = [];
237
+
238
+ constructor(
239
+ private readonly transport: RealtimeTransport,
240
+ private readonly topic: string,
241
+ ) {}
242
+
243
+ /** Register a handler for an event kind on this topic. Defaults the payload
244
+ * to the subscription's `TPayload`; pass an explicit `T` to narrow further. */
245
+ on<T = TPayload>(event: RealtimeEvent, handler: RealtimeHandler<T>): this {
246
+ void this.transport
247
+ .subscribe(this.topic, event, handler as RealtimeHandler)
248
+ .then((dispose) => this.disposers.push(dispose));
249
+ return this;
250
+ }
251
+
252
+ /** Stop all handlers attached through this handle. */
253
+ close(): void {
254
+ for (const dispose of this.disposers) dispose();
255
+ this.disposers.length = 0;
256
+ }
257
+ }