@rocksky/sdk 0.1.0

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +279 -0
  3. package/dist/client.d.ts +107 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/errors.d.ts +26 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/http.d.ts +28 -0
  8. package/dist/http.d.ts.map +1 -0
  9. package/dist/index.d.ts +18 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +1492 -0
  12. package/dist/namespaces/_helpers.d.ts +9 -0
  13. package/dist/namespaces/_helpers.d.ts.map +1 -0
  14. package/dist/namespaces/actor.d.ts +30 -0
  15. package/dist/namespaces/actor.d.ts.map +1 -0
  16. package/dist/namespaces/album.d.ts +18 -0
  17. package/dist/namespaces/album.d.ts.map +1 -0
  18. package/dist/namespaces/apikey.d.ts +27 -0
  19. package/dist/namespaces/apikey.d.ts.map +1 -0
  20. package/dist/namespaces/artist.d.ts +31 -0
  21. package/dist/namespaces/artist.d.ts.map +1 -0
  22. package/dist/namespaces/charts.d.ts +25 -0
  23. package/dist/namespaces/charts.d.ts.map +1 -0
  24. package/dist/namespaces/dropbox.d.ts +19 -0
  25. package/dist/namespaces/dropbox.d.ts.map +1 -0
  26. package/dist/namespaces/feed.d.ts +38 -0
  27. package/dist/namespaces/feed.d.ts.map +1 -0
  28. package/dist/namespaces/googledrive.d.ts +16 -0
  29. package/dist/namespaces/googledrive.d.ts.map +1 -0
  30. package/dist/namespaces/graph.d.ts +28 -0
  31. package/dist/namespaces/graph.d.ts.map +1 -0
  32. package/dist/namespaces/like.d.ts +14 -0
  33. package/dist/namespaces/like.d.ts.map +1 -0
  34. package/dist/namespaces/mirror.d.ts +15 -0
  35. package/dist/namespaces/mirror.d.ts.map +1 -0
  36. package/dist/namespaces/player.d.ts +41 -0
  37. package/dist/namespaces/player.d.ts.map +1 -0
  38. package/dist/namespaces/playlist.d.ts +40 -0
  39. package/dist/namespaces/playlist.d.ts.map +1 -0
  40. package/dist/namespaces/scrobble.d.ts +43 -0
  41. package/dist/namespaces/scrobble.d.ts.map +1 -0
  42. package/dist/namespaces/shout.d.ts +38 -0
  43. package/dist/namespaces/shout.d.ts.map +1 -0
  44. package/dist/namespaces/song.d.ts +48 -0
  45. package/dist/namespaces/song.d.ts.map +1 -0
  46. package/dist/namespaces/spotify.d.ts +17 -0
  47. package/dist/namespaces/spotify.d.ts.map +1 -0
  48. package/dist/namespaces/stats.d.ts +14 -0
  49. package/dist/namespaces/stats.d.ts.map +1 -0
  50. package/dist/paginate.d.ts +38 -0
  51. package/dist/paginate.d.ts.map +1 -0
  52. package/dist/pipe.d.ts +36 -0
  53. package/dist/pipe.d.ts.map +1 -0
  54. package/dist/realtime.d.ts +144 -0
  55. package/dist/realtime.d.ts.map +1 -0
  56. package/dist/types.d.ts +29 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/package.json +65 -0
  59. package/src/client.ts +235 -0
  60. package/src/errors.ts +47 -0
  61. package/src/http.ts +195 -0
  62. package/src/index.ts +85 -0
  63. package/src/namespaces/_helpers.ts +17 -0
  64. package/src/namespaces/actor.ts +89 -0
  65. package/src/namespaces/album.ts +34 -0
  66. package/src/namespaces/apikey.ts +50 -0
  67. package/src/namespaces/artist.ts +74 -0
  68. package/src/namespaces/charts.ts +47 -0
  69. package/src/namespaces/dropbox.ts +47 -0
  70. package/src/namespaces/feed.ts +106 -0
  71. package/src/namespaces/googledrive.ts +36 -0
  72. package/src/namespaces/graph.ts +63 -0
  73. package/src/namespaces/like.ts +40 -0
  74. package/src/namespaces/mirror.ts +31 -0
  75. package/src/namespaces/player.ts +127 -0
  76. package/src/namespaces/playlist.ts +85 -0
  77. package/src/namespaces/scrobble.ts +67 -0
  78. package/src/namespaces/shout.ts +90 -0
  79. package/src/namespaces/song.ts +81 -0
  80. package/src/namespaces/spotify.ts +52 -0
  81. package/src/namespaces/stats.ts +23 -0
  82. package/src/paginate.ts +90 -0
  83. package/src/pipe.ts +146 -0
  84. package/src/realtime.ts +408 -0
  85. package/src/types.ts +41 -0
@@ -0,0 +1,408 @@
1
+ import { RockskyError } from "./errors";
2
+ import { DEFAULT_BASE_URL } from "./types";
3
+
4
+ /** Minimal WebSocket interface — works with browser `WebSocket`, `ws`, `bun`. */
5
+ export interface WebSocketLike {
6
+ readyState: number;
7
+ send(data: string): void;
8
+ close(code?: number, reason?: string): void;
9
+ addEventListener(type: "open", listener: () => void): void;
10
+ addEventListener(
11
+ type: "close",
12
+ listener: (event: { code: number; reason: string }) => void,
13
+ ): void;
14
+ addEventListener(type: "error", listener: (event: unknown) => void): void;
15
+ addEventListener(
16
+ type: "message",
17
+ listener: (event: { data: unknown }) => void,
18
+ ): void;
19
+ removeEventListener(type: string, listener: (event: unknown) => void): void;
20
+ }
21
+
22
+ export type WebSocketCtor = new (url: string) => WebSocketLike;
23
+
24
+ export type ReconnectOptions = {
25
+ backoffMs?: number;
26
+ maxBackoffMs?: number;
27
+ maxAttempts?: number;
28
+ factor?: number;
29
+ };
30
+
31
+ export type RealtimeOptions = {
32
+ /** Full WebSocket URL — defaults to ws(s)://api.rocksky.app/ws (derived from baseUrl). */
33
+ url?: string;
34
+ /** Used to derive the WS URL when `url` is not set. Default: DEFAULT_BASE_URL. */
35
+ baseUrl?: string;
36
+ /** JWT token required to register and send messages. */
37
+ token: string | (() => string | Promise<string>);
38
+ /** Display name advertised to the server when registering. */
39
+ clientName?: string;
40
+ /** Auto-register on connect. Default: true. */
41
+ autoRegister?: boolean;
42
+ /** Reconnect on close. Pass `false` to disable. Default: `{ backoffMs: 500, factor: 2, maxBackoffMs: 30000 }`. */
43
+ reconnect?: boolean | ReconnectOptions;
44
+ /** Send `ping` every N ms to keep the socket warm. Default: 25000. Pass 0 to disable. */
45
+ pingIntervalMs?: number;
46
+ /** WebSocket constructor — defaults to global `WebSocket`. Pass a fake for tests. */
47
+ webSocket?: WebSocketCtor;
48
+ };
49
+
50
+ export type RealtimeEventMap = {
51
+ open: void;
52
+ close: { code: number; reason: string };
53
+ error: unknown;
54
+ registered: { deviceId: string };
55
+ deviceRegistered: { deviceId: string; clientName: string };
56
+ message: { data: unknown; device_id: string };
57
+ control: { type: string; action: string; args?: unknown };
58
+ raw: unknown;
59
+ };
60
+
61
+ export type RealtimeEvent = keyof RealtimeEventMap;
62
+
63
+ const RECONNECT_DEFAULTS: Required<ReconnectOptions> = {
64
+ backoffMs: 500,
65
+ maxBackoffMs: 30_000,
66
+ maxAttempts: Number.POSITIVE_INFINITY,
67
+ factor: 2,
68
+ };
69
+
70
+ export class RealtimeClient {
71
+ private ws?: WebSocketLike;
72
+ private listeners = new Map<RealtimeEvent, Set<(payload: unknown) => void>>();
73
+ private reconnectAttempts = 0;
74
+ private pingTimer?: ReturnType<typeof setInterval>;
75
+ private closed = false;
76
+ private deviceId?: string;
77
+ private readonly url: string;
78
+ private readonly reconnectOpts: Required<ReconnectOptions> | null;
79
+ private readonly WsCtor: WebSocketCtor;
80
+
81
+ constructor(private readonly options: RealtimeOptions) {
82
+ this.url = options.url ?? deriveWsUrl(options.baseUrl ?? DEFAULT_BASE_URL);
83
+ this.reconnectOpts =
84
+ options.reconnect === false
85
+ ? null
86
+ : {
87
+ ...RECONNECT_DEFAULTS,
88
+ ...(typeof options.reconnect === "object" ? options.reconnect : {}),
89
+ };
90
+ const ctor = options.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
91
+ if (!ctor) {
92
+ throw new RockskyError(
93
+ "No WebSocket implementation available — pass `webSocket` in options",
94
+ );
95
+ }
96
+ this.WsCtor = ctor;
97
+ }
98
+
99
+ /** Currently registered device ID (set after the server confirms registration). */
100
+ get currentDeviceId(): string | undefined {
101
+ return this.deviceId;
102
+ }
103
+
104
+ /** Whether the underlying socket is OPEN. */
105
+ get isOpen(): boolean {
106
+ return this.ws?.readyState === 1;
107
+ }
108
+
109
+ on<E extends RealtimeEvent>(
110
+ event: E,
111
+ listener: (payload: RealtimeEventMap[E]) => void,
112
+ ): () => void {
113
+ const set = this.listeners.get(event) ?? new Set();
114
+ set.add(listener as (p: unknown) => void);
115
+ this.listeners.set(event, set);
116
+ return () => this.off(event, listener);
117
+ }
118
+
119
+ off<E extends RealtimeEvent>(
120
+ event: E,
121
+ listener: (payload: RealtimeEventMap[E]) => void,
122
+ ): void {
123
+ this.listeners.get(event)?.delete(listener as (p: unknown) => void);
124
+ }
125
+
126
+ private emit<E extends RealtimeEvent>(
127
+ event: E,
128
+ payload: RealtimeEventMap[E],
129
+ ): void {
130
+ const set = this.listeners.get(event);
131
+ if (!set) return;
132
+ for (const fn of set) {
133
+ try {
134
+ fn(payload);
135
+ } catch (err) {
136
+ const errSet = this.listeners.get("error");
137
+ errSet?.forEach((f) => f(err));
138
+ }
139
+ }
140
+ }
141
+
142
+ /** Open the socket. Resolves once the WebSocket reaches OPEN. */
143
+ async connect(): Promise<void> {
144
+ this.closed = false;
145
+ const ws = new this.WsCtor(this.url);
146
+ this.ws = ws;
147
+ await new Promise<void>((resolve, reject) => {
148
+ const onOpen = () => {
149
+ ws.removeEventListener("open", onOpen as never);
150
+ ws.removeEventListener("error", onError as never);
151
+ resolve();
152
+ };
153
+ const onError = (err: unknown) => {
154
+ ws.removeEventListener("open", onOpen as never);
155
+ ws.removeEventListener("error", onError as never);
156
+ reject(err);
157
+ };
158
+ ws.addEventListener("open", onOpen);
159
+ ws.addEventListener("error", onError);
160
+ });
161
+ this.attachHandlers(ws);
162
+ this.reconnectAttempts = 0;
163
+ this.emit("open", undefined);
164
+ if (this.options.autoRegister !== false) {
165
+ await this.register();
166
+ }
167
+ this.startPing();
168
+ }
169
+
170
+ /** Manually register this connection with the server. */
171
+ async register(): Promise<void> {
172
+ const token = await resolveToken(this.options.token);
173
+ this.sendRaw({
174
+ type: "register",
175
+ clientName: this.options.clientName ?? "rocksky-sdk",
176
+ token,
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Broadcast a data message to all of the user's connected devices. The
182
+ * server uses `data.type === "track"` for now-playing updates.
183
+ */
184
+ async sendMessage(data: Record<string, unknown>): Promise<void> {
185
+ if (!this.deviceId) {
186
+ throw new RockskyError(
187
+ "Cannot send message — device not registered yet (await connect() first)",
188
+ );
189
+ }
190
+ const token = await resolveToken(this.options.token);
191
+ this.sendRaw({
192
+ type: "message",
193
+ data,
194
+ device_id: this.deviceId,
195
+ token,
196
+ });
197
+ }
198
+
199
+ /** Send a control message to one or all devices. */
200
+ async sendControl(args: {
201
+ type?: string;
202
+ target?: string;
203
+ action: string;
204
+ args?: unknown;
205
+ }): Promise<void> {
206
+ const token = await resolveToken(this.options.token);
207
+ this.sendRaw({
208
+ type: args.type ?? "control",
209
+ target: args.target,
210
+ action: args.action,
211
+ args: args.args,
212
+ token,
213
+ });
214
+ }
215
+
216
+ /** Send a heartbeat ping. Server responds with "pong". */
217
+ ping(): void {
218
+ if (this.ws?.readyState === 1) this.ws.send("ping");
219
+ }
220
+
221
+ /** Send a raw message — escape hatch for unsupported message shapes. */
222
+ sendRaw(payload: unknown): void {
223
+ if (this.ws?.readyState !== 1) {
224
+ throw new RockskyError("WebSocket is not open");
225
+ }
226
+ this.ws.send(typeof payload === "string" ? payload : JSON.stringify(payload));
227
+ }
228
+
229
+ /** Close the socket and disable reconnect. */
230
+ async close(code = 1000, reason = "client closing"): Promise<void> {
231
+ this.closed = true;
232
+ this.stopPing();
233
+ this.ws?.close(code, reason);
234
+ }
235
+
236
+ private attachHandlers(ws: WebSocketLike): void {
237
+ ws.addEventListener("message", (event) => {
238
+ const raw = event.data;
239
+ if (raw === "pong") return;
240
+ let parsed: unknown;
241
+ try {
242
+ parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
243
+ } catch {
244
+ this.emit("raw", raw);
245
+ return;
246
+ }
247
+ this.emit("raw", parsed);
248
+ this.dispatch(parsed);
249
+ });
250
+ ws.addEventListener("close", (event) => {
251
+ this.stopPing();
252
+ this.emit("close", event);
253
+ if (!this.closed && this.reconnectOpts) this.scheduleReconnect();
254
+ });
255
+ ws.addEventListener("error", (err) => {
256
+ this.emit("error", err);
257
+ });
258
+ }
259
+
260
+ private dispatch(msg: unknown): void {
261
+ if (!msg || typeof msg !== "object") return;
262
+ const m = msg as Record<string, unknown>;
263
+ if (m.status === "registered" && typeof m.deviceId === "string") {
264
+ this.deviceId = m.deviceId;
265
+ this.emit("registered", { deviceId: m.deviceId });
266
+ return;
267
+ }
268
+ if (m.type === "device_registered" && typeof m.deviceId === "string") {
269
+ this.emit("deviceRegistered", {
270
+ deviceId: m.deviceId,
271
+ clientName: String(m.clientName ?? ""),
272
+ });
273
+ return;
274
+ }
275
+ if (m.type === "message" && typeof m.device_id === "string") {
276
+ this.emit("message", {
277
+ data: m.data,
278
+ device_id: m.device_id,
279
+ });
280
+ return;
281
+ }
282
+ if (typeof m.action === "string") {
283
+ this.emit("control", {
284
+ type: String(m.type ?? "control"),
285
+ action: m.action,
286
+ args: m.args,
287
+ });
288
+ }
289
+ }
290
+
291
+ private scheduleReconnect(): void {
292
+ if (!this.reconnectOpts) return;
293
+ if (this.reconnectAttempts >= this.reconnectOpts.maxAttempts) return;
294
+ const delay = Math.min(
295
+ this.reconnectOpts.backoffMs *
296
+ Math.pow(this.reconnectOpts.factor, this.reconnectAttempts),
297
+ this.reconnectOpts.maxBackoffMs,
298
+ );
299
+ this.reconnectAttempts++;
300
+ setTimeout(() => {
301
+ if (this.closed) return;
302
+ this.connect().catch((err) => this.emit("error", err));
303
+ }, delay);
304
+ }
305
+
306
+ private startPing(): void {
307
+ const ms = this.options.pingIntervalMs ?? 25_000;
308
+ if (ms <= 0) return;
309
+ this.pingTimer = setInterval(() => this.ping(), ms);
310
+ }
311
+
312
+ private stopPing(): void {
313
+ if (this.pingTimer) {
314
+ clearInterval(this.pingTimer);
315
+ this.pingTimer = undefined;
316
+ }
317
+ }
318
+ }
319
+
320
+ export function createRealtimeClient(opts: RealtimeOptions): RealtimeClient {
321
+ return new RealtimeClient(opts);
322
+ }
323
+
324
+ /**
325
+ * Fluent builder for the realtime client.
326
+ *
327
+ * const rt = RealtimeClient.builder()
328
+ * .baseUrl("https://api.rocksky.app")
329
+ * .token(() => loadToken())
330
+ * .clientName("my-app")
331
+ * .pingInterval(20_000)
332
+ * .reconnect({ backoffMs: 1000, maxBackoffMs: 60_000 })
333
+ * .build();
334
+ *
335
+ * await rt.connect();
336
+ */
337
+ export class RealtimeClientBuilder {
338
+ private opts: Partial<RealtimeOptions> = {};
339
+
340
+ url(u: string): this {
341
+ this.opts.url = u;
342
+ return this;
343
+ }
344
+
345
+ baseUrl(u: string): this {
346
+ this.opts.baseUrl = u;
347
+ return this;
348
+ }
349
+
350
+ token(t: RealtimeOptions["token"]): this {
351
+ this.opts.token = t;
352
+ return this;
353
+ }
354
+
355
+ clientName(name: string): this {
356
+ this.opts.clientName = name;
357
+ return this;
358
+ }
359
+
360
+ autoRegister(value: boolean): this {
361
+ this.opts.autoRegister = value;
362
+ return this;
363
+ }
364
+
365
+ reconnect(value: boolean | ReconnectOptions): this {
366
+ this.opts.reconnect = value;
367
+ return this;
368
+ }
369
+
370
+ pingInterval(ms: number): this {
371
+ this.opts.pingIntervalMs = ms;
372
+ return this;
373
+ }
374
+
375
+ webSocket(ctor: WebSocketCtor): this {
376
+ this.opts.webSocket = ctor;
377
+ return this;
378
+ }
379
+
380
+ build(): RealtimeClient {
381
+ if (!this.opts.token) {
382
+ throw new RockskyError(
383
+ "RealtimeClientBuilder: token() is required before build()",
384
+ );
385
+ }
386
+ return new RealtimeClient(this.opts as RealtimeOptions);
387
+ }
388
+ }
389
+
390
+ // biome-ignore lint/suspicious/noShadowRestrictedNames: namespace augmentation
391
+ export namespace RealtimeClient {
392
+ export function builder(): RealtimeClientBuilder {
393
+ return new RealtimeClientBuilder();
394
+ }
395
+ }
396
+
397
+ function deriveWsUrl(httpBase: string): string {
398
+ const u = new URL(httpBase);
399
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
400
+ u.pathname = u.pathname.replace(/\/$/, "") + "/ws";
401
+ return u.toString();
402
+ }
403
+
404
+ async function resolveToken(
405
+ source: string | (() => string | Promise<string>),
406
+ ): Promise<string> {
407
+ return typeof source === "function" ? await source() : source;
408
+ }
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ export type Json =
2
+ | null
3
+ | boolean
4
+ | number
5
+ | string
6
+ | Json[]
7
+ | { [key: string]: Json };
8
+
9
+ export type FetchLike = (
10
+ input: string | URL,
11
+ init?: RequestInit,
12
+ ) => Promise<Response>;
13
+
14
+ export type AuthProvider = string | (() => string | Promise<string>);
15
+
16
+ export type ClientOptions = {
17
+ baseUrl?: string;
18
+ auth?: AuthProvider;
19
+ fetch?: FetchLike;
20
+ headers?: Record<string, string>;
21
+ timeoutMs?: number;
22
+ retries?: number;
23
+ retryDelayMs?: number;
24
+ userAgent?: string;
25
+ };
26
+
27
+ export type RequestOptions = {
28
+ signal?: AbortSignal;
29
+ headers?: Record<string, string>;
30
+ timeoutMs?: number;
31
+ retries?: number;
32
+ auth?: AuthProvider | null;
33
+ };
34
+
35
+ export type Pagination = {
36
+ limit?: number;
37
+ offset?: number;
38
+ cursor?: string;
39
+ };
40
+
41
+ export const DEFAULT_BASE_URL = "https://api.rocksky.app";