@omelhorsite/sdk 0.2.0 → 0.3.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 (44) hide show
  1. package/dist/index.js +4939 -552
  2. package/dist/types/client.d.ts +60 -3
  3. package/dist/types/http.d.ts +444 -19
  4. package/dist/types/index.d.ts +4 -1
  5. package/dist/types/resources/account.d.ts +66 -3
  6. package/dist/types/resources/admin.d.ts +1837 -0
  7. package/dist/types/resources/auth/index.d.ts +39 -0
  8. package/dist/types/resources/auth/passkeys.d.ts +652 -0
  9. package/dist/types/resources/auth/sessions.d.ts +847 -0
  10. package/dist/types/resources/chests.d.ts +54 -3
  11. package/dist/types/resources/content.d.ts +2970 -0
  12. package/dist/types/resources/dynamicQrs.d.ts +39 -3
  13. package/dist/types/resources/forms.d.ts +176 -35
  14. package/dist/types/resources/index.d.ts +19 -8
  15. package/dist/types/resources/ipLookup.d.ts +20 -4
  16. package/dist/types/resources/jobs.d.ts +62 -21
  17. package/dist/types/resources/library.d.ts +1435 -0
  18. package/dist/types/resources/linkTrees.d.ts +142 -30
  19. package/dist/types/resources/media.d.ts +351 -0
  20. package/dist/types/resources/movies.d.ts +1186 -0
  21. package/dist/types/resources/music/artists.d.ts +1066 -0
  22. package/dist/types/resources/music/imports.d.ts +940 -0
  23. package/dist/types/resources/music/index.d.ts +61 -0
  24. package/dist/types/resources/music/playlists.d.ts +1026 -0
  25. package/dist/types/resources/music/social.d.ts +1132 -0
  26. package/dist/types/resources/music/songs.d.ts +1183 -0
  27. package/dist/types/resources/notepads.d.ts +4 -1
  28. package/dist/types/resources/quotas.d.ts +7 -1
  29. package/dist/types/resources/realtime.d.ts +855 -0
  30. package/dist/types/resources/shortLinks.d.ts +45 -4
  31. package/dist/types/resources/social.d.ts +1330 -0
  32. package/dist/types/resources/storage/upload.d.ts +158 -11
  33. package/dist/types/resources/storage.d.ts +88 -22
  34. package/dist/types/resources/tickets.d.ts +82 -3
  35. package/dist/types/resources/tools/backgroundRemoval.d.ts +18 -3
  36. package/dist/types/resources/tools/captions.d.ts +448 -21
  37. package/dist/types/resources/tools/downloader.d.ts +21 -0
  38. package/dist/types/resources/tools/index.d.ts +57 -15
  39. package/dist/types/resources/tools/jumpstyle.d.ts +50 -17
  40. package/dist/types/resources/tools/transcription.d.ts +35 -13
  41. package/dist/types/resources/tools/upscale.d.ts +23 -3
  42. package/dist/types/resources/tools/vocalSeparation.d.ts +30 -13
  43. package/dist/types/types.d.ts +249 -17
  44. package/package.json +2 -1
@@ -0,0 +1,855 @@
1
+ /**
2
+ * The `realtime` namespace: ActionCable over a raw WebSocket.
3
+ *
4
+ * This is the only transport in the SDK that is not HTTP, and it is the reason
5
+ * "the frontend uses 100% of the SDK" can be true rather than nearly true.
6
+ * Everything else here talks to `/`-rooted JSON endpoints through
7
+ * {@link ApiClient}; this talks the ActionCable v1 wire protocol to `/cable`,
8
+ * and it deliberately does NOT depend on `@rails/actioncable` - that package
9
+ * assumes a browser, pulls in its own global logger, and is bigger than the
10
+ * eighty lines of protocol it implements.
11
+ *
12
+ * ## The protocol, in full
13
+ *
14
+ * Server to client, one JSON object per frame:
15
+ *
16
+ * - `{"type":"welcome"}` - the connection is live. NOTHING may be sent before
17
+ * this arrives; the server drops earlier frames on the floor without an
18
+ * error, so a client that subscribes on `open` subscribes to nothing;
19
+ * - `{"type":"ping","message":<epoch seconds>}` - every ~3 s, unconditionally,
20
+ * even on an idle connection. This is the only liveness signal there is;
21
+ * - `{"type":"confirm_subscription","identifier":"..."}`;
22
+ * - `{"type":"reject_subscription","identifier":"..."}`;
23
+ * - `{"type":"disconnect","reason":"...","reconnect":true|false}`;
24
+ * - `{"identifier":"...","message":<payload>}` for everything a channel
25
+ * actually streams. This frame has no `type` of its own - the `type` you
26
+ * read in application code lives one level down, inside `message`.
27
+ *
28
+ * Client to server:
29
+ *
30
+ * - `{"command":"subscribe","identifier":"..."}`;
31
+ * - `{"command":"unsubscribe","identifier":"..."}`;
32
+ * - `{"command":"message","identifier":"...","data":"<JSON string>"}` - note
33
+ * that `data` is a STRING containing JSON, not an object, and that the
34
+ * action name travels inside it as `{"action":"...", ...args}`.
35
+ *
36
+ * ## The identifier is a string, and it is compared as one
37
+ *
38
+ * `identifier` is a JSON-encoded string such as `'{"channel":"PlaybackChannel"}'`.
39
+ * The server echoes back the exact bytes it received and Rails routes incoming
40
+ * frames by looking that string up in a map. Re-serialising with the keys in a
41
+ * different order produces a different string, the lookup misses, and the
42
+ * symptom is not an error: it is a subscription that confirms and then never
43
+ * receives anything. So this module builds the identifier ONCE per
44
+ * subscription, keys its own registry by that same string, and never rebuilds
45
+ * it - including across a reconnect.
46
+ *
47
+ * ## Four traps, all of them confirmed against the Rails source
48
+ *
49
+ * 1. **The handshake authenticates on the FIRST candidate, with no fallback.**
50
+ * `ApplicationCable::Connection#find_session` calls
51
+ * `Session.token_from_request`, which is `candidate_tokens(request).first`:
52
+ * the `Authorization` header, then `?token=`, then the session cookie, and
53
+ * it takes the first one that is merely NON-BLANK. Contrast the HTTP path,
54
+ * which uses `Session.resolve_from_request` and tries each candidate until
55
+ * one resolves to a live session. A stale `Authorization` header therefore
56
+ * shadows a perfectly good cookie on the cable and on the cable only. This
57
+ * module never sends an `Authorization` header on the handshake (browsers
58
+ * cannot attach one to a WebSocket anyway) and puts the token in the query
59
+ * string, exactly as `oms-music` does.
60
+ * 2. **Anonymous connections are ACCEPTED.** `connect` sets `current_user` to
61
+ * `nil` and returns; it does not `reject_unauthorized_connection`. A bad
62
+ * token, an expired token, or no token at all produces a perfectly healthy
63
+ * socket that says `welcome` and then pings forever. The identity failure
64
+ * surfaces ONE LEVEL DOWN, as `reject_subscription` on each channel that
65
+ * needs a user. A client that only handles connection errors will sit there
66
+ * believing it is signed in. Handle {@link CableHandlers.onReject}, always.
67
+ * 3. **Only legacy `Session` tokens work.** `find_session` ends in
68
+ * `Session.find_by(token:)` - it never looks at `oauth_access_tokens`. An
69
+ * OAuth access token that authenticates every REST call in this SDK
70
+ * produces an ANONYMOUS cable connection, i.e. trap 2. There is no scope
71
+ * that fixes this and no error that says it; you get silent rejections on
72
+ * every channel. Cable access needs a session token or the session cookie.
73
+ * 4. **`/cable` is exempt from the rate limits.** It is on rack-attack's
74
+ * allowlist, so neither the 600/min authed ceiling nor the 120/min anonymous
75
+ * one applies to the handshake or to anything sent over the socket. That is
76
+ * a convenience for `position_tick` at 1 Hz, not a licence: the reconnect
77
+ * backoff below exists because an unthrottled reconnect loop against a
78
+ * restarting server is a self-inflicted denial of service.
79
+ *
80
+ * ## Injecting the socket
81
+ *
82
+ * The WebSocket constructor is a parameter, not a global lookup. Three reasons,
83
+ * in ascending order of importance: `globalThis.WebSocket` does not exist on
84
+ * every runtime the SDK targets; a host that wants a proxy, a header, or a
85
+ * permessage-deflate setting has nowhere else to put it; and without injection
86
+ * there is no way to test any of this without a network. The default factory
87
+ * reads `globalThis.WebSocket` if it is there and throws a message naming the
88
+ * option if it is not.
89
+ *
90
+ * ```ts
91
+ * const cable = oms.realtime.connect({ token: sessionToken });
92
+ * const sub = cable.notifications({
93
+ * onMessage: (msg) => { if (msg.type === "created") show(msg.notification); },
94
+ * onReject: () => { /* anonymous or logged out - see trap 2 *\/ },
95
+ * });
96
+ * // later
97
+ * sub.unsubscribe();
98
+ * cable.close();
99
+ * ```
100
+ */
101
+ import { Resource } from "../http";
102
+ /**
103
+ * The slice of the WHATWG `WebSocket` this module actually uses.
104
+ *
105
+ * Deliberately structural and deliberately small: a browser `WebSocket`, a
106
+ * Bun one, React Native's polyfill and a hand-written test double all satisfy
107
+ * it without a cast. Note that the handlers are ASSIGNED (`socket.onmessage =`)
108
+ * rather than added as listeners, because that is the subset every one of those
109
+ * four implements identically.
110
+ */
111
+ export interface CableSocket {
112
+ /** `0` connecting, `1` open, `2` closing, `3` closed. See {@link SOCKET_OPEN}. */
113
+ readonly readyState: number;
114
+ send(data: string): void;
115
+ close(code?: number, reason?: string): void;
116
+ onopen: ((event: unknown) => void) | null;
117
+ onmessage: ((event: {
118
+ data: unknown;
119
+ }) => void) | null;
120
+ onclose: ((event: unknown) => void) | null;
121
+ onerror: ((event: unknown) => void) | null;
122
+ }
123
+ /** `WebSocket.OPEN`, spelled out so this module never touches the global. */
124
+ export declare const SOCKET_OPEN = 1;
125
+ /**
126
+ * Builds a socket for a URL. This is the injection point.
127
+ *
128
+ * It is called once per connection attempt, so a factory that wants to add a
129
+ * subprotocol, an agent or a proxy gets a fresh chance on every reconnect.
130
+ */
131
+ export type CableSocketFactory = (url: string) => CableSocket;
132
+ /**
133
+ * The clock and the timer, injected together so tests are deterministic.
134
+ *
135
+ * The reconnect backoff and the ping watchdog are the two things in here that
136
+ * are hard to test against a real clock and easy to test against a fake one.
137
+ * Defaults to `globalThis`.
138
+ */
139
+ export interface CableTimers {
140
+ setTimeout(handler: () => void, ms: number): unknown;
141
+ clearTimeout(handle: unknown): void;
142
+ /** Milliseconds since the epoch. Only differences are ever used. */
143
+ now(): number;
144
+ }
145
+ /** A credential for the handshake: a token, a thunk, or nothing (cookie auth). */
146
+ export type CableCredential = string | null | undefined | (() => string | null | undefined | Promise<string | null | undefined>);
147
+ /** Options for {@link RealtimeNamespace.connect}. */
148
+ export interface CableConnectOptions {
149
+ /**
150
+ * The credential, sent as `?token=` on the handshake URL.
151
+ *
152
+ * Omit it (or pass `null`) for cookie auth: on a first-party page the
153
+ * browser attaches the httpOnly session cookie to a same-site WebSocket
154
+ * handshake by itself, and the server's third credential candidate picks it
155
+ * up. Omitting is NOT the same as passing `""` in intent, but it is the same
156
+ * on the wire - both produce a bare `/cable` - so either works.
157
+ *
158
+ * A function is resolved on every connection attempt, which is what makes a
159
+ * rotating token survive a reconnect. It must not throw; if it does, the
160
+ * attempt is treated as a failed connect and retried on the backoff.
161
+ *
162
+ * Read trap 3 in the module docs before passing an OAuth access token here:
163
+ * the cable does not understand them and will silently connect as nobody.
164
+ */
165
+ readonly token?: CableCredential;
166
+ /**
167
+ * How to build the socket. Required on any runtime without a global
168
+ * `WebSocket`, and the seam every test uses.
169
+ */
170
+ readonly socket?: CableSocketFactory;
171
+ /** Clock and timer. Defaults to `globalThis`. */
172
+ readonly timers?: CableTimers;
173
+ /**
174
+ * Cable endpoint, absolute or relative to the API root. Defaults to
175
+ * `/cable` under the client's `baseUrl`, with `http`/`https` rewritten to
176
+ * `ws`/`wss`.
177
+ */
178
+ readonly path?: string;
179
+ /** First reconnect delay in ms. Defaults to 1000. */
180
+ readonly reconnectInitialMs?: number;
181
+ /** Ceiling for the doubling backoff in ms. Defaults to 30000. */
182
+ readonly reconnectMaxMs?: number;
183
+ /**
184
+ * How long a welcomed socket may go without a frame before it is presumed
185
+ * dead and cycled. Defaults to 25000, against a ~3 s server ping.
186
+ *
187
+ * `0` disables the watchdog. Disable it only if you have another liveness
188
+ * signal: a TCP connection that a NAT or a sleeping radio dropped stays
189
+ * `readyState === 1` forever on several runtimes, so `onclose` never fires
190
+ * and, without this, neither does the reconnect.
191
+ */
192
+ readonly staleAfterMs?: number;
193
+ /** Called on every connection-level state transition. */
194
+ readonly onStateChange?: (state: CableState) => void;
195
+ }
196
+ /**
197
+ * Connection state.
198
+ *
199
+ * `"connected"` means WELCOMED, not merely open: a socket that has completed
200
+ * the TCP and HTTP upgrade but not yet received `welcome` cannot carry a single
201
+ * command, so calling it connected would be a lie the caller acts on.
202
+ */
203
+ export type CableState = "closed" | "connecting" | "connected";
204
+ /** Per-subscription callbacks. All optional except {@link onMessage}. */
205
+ export interface CableHandlers<TMessage = unknown> {
206
+ /**
207
+ * One stream payload. This is the `message` field of the frame, already
208
+ * parsed - the envelope never reaches here.
209
+ */
210
+ onMessage(message: TMessage): void;
211
+ /**
212
+ * `confirm_subscription` arrived. Fires again after every reconnect, because
213
+ * a reconnect re-subscribes; treat it as "the channel is live now", not as
214
+ * "the channel just became live for the first time".
215
+ */
216
+ onConfirm?(): void;
217
+ /**
218
+ * `reject_subscription` arrived. On this API that almost always means one of
219
+ * two things and they are worth telling apart: the connection is anonymous
220
+ * (trap 2 - a bad token, an OAuth token, or no credential), or the specific
221
+ * resource is gone or was never yours (a jam that ended, a job you may not
222
+ * read). Neither is retryable by resubscribing; both need the caller to do
223
+ * something.
224
+ */
225
+ onReject?(): void;
226
+ /**
227
+ * The underlying socket dropped while this subscription was live. The
228
+ * subscription is NOT cancelled - see {@link CableSubscription} - so this is
229
+ * a "show the reconnecting spinner" signal, not a teardown.
230
+ */
231
+ onDisconnect?(): void;
232
+ }
233
+ /** A live subscription. */
234
+ export interface CableSubscription {
235
+ /**
236
+ * The exact identifier string this subscription is keyed by. Exposed because
237
+ * it is the thing to log when a channel confirms and then stays silent.
238
+ */
239
+ readonly identifier: string;
240
+ /**
241
+ * Sends `{"command":"message"}` with `{"action": action, ...data}`.
242
+ *
243
+ * A no-op when the connection is not welcomed, matching the server, which
244
+ * discards pre-welcome frames without answering. There is deliberately no
245
+ * queue: every action on this API is either idempotent state (a snapshot
246
+ * request, a heartbeat) or positional and stale by the time a socket comes
247
+ * back (a position tick, a seek). Replaying them on reconnect would push
248
+ * playback backwards.
249
+ */
250
+ perform(action: string, data?: Record<string, unknown>): void;
251
+ /** Unsubscribes and forgets the identifier. Idempotent. */
252
+ unsubscribe(): void;
253
+ /** False once {@link unsubscribe} has run. */
254
+ readonly active: boolean;
255
+ }
256
+ /**
257
+ * Every channel class under `backend/app/channels`, as of this writing.
258
+ *
259
+ * There are exactly five, and none of them is generic: each has its own
260
+ * subscription params, its own rejection rule, and its own message vocabulary.
261
+ * That is why this module exposes five typed helpers rather than one
262
+ * `subscribe(channel, params)` - a caller who has to remember that
263
+ * `JobChannel` takes a string id and `JamChannel` takes a number will get it
264
+ * wrong, and the failure mode is a subscription that confirms and then never
265
+ * fires.
266
+ *
267
+ * {@link CableConnection.channel} is still there for a sixth channel that
268
+ * ships before this file is updated.
269
+ */
270
+ export declare const CABLE_CHANNELS: readonly ["PlaybackChannel", "JamChannel", "FriendListeningChannel", "JobChannel", "NotificationsChannel"];
271
+ /** One of {@link CABLE_CHANNELS}. */
272
+ export type CableChannelName = (typeof CABLE_CHANNELS)[number];
273
+ /**
274
+ * The playback state as the cable serialises it.
275
+ *
276
+ * **The id types here are not the ones the app docs promise.** `oms-music`'s
277
+ * `API.md` says flatly that "on the cable, song ids and queue entries are
278
+ * STRINGS". Half of that is true and the Rails source says which half:
279
+ *
280
+ * - `queue` is a `jsonb` column that the channel writes as
281
+ * `Array(attrs["queue"]).map(&:to_s)`, so it really is `string[]` coming
282
+ * back, whatever the client sent;
283
+ * - `song_id` is a `bigint` COLUMN (`db/schema.rb`, `playback_states`). You
284
+ * send it as a string, Rails casts it on write, and it comes back as a JSON
285
+ * NUMBER. It is not a string, and code that does `state.song_id === queue[i]`
286
+ * compares a number to a string and silently never matches.
287
+ *
288
+ * The rule that does hold everywhere is: normalise with `String(...)` at the
289
+ * boundary and compare strings. This SDK types the fields as the wire actually
290
+ * carries them rather than as the doc wishes they were, so the mismatch is a
291
+ * type error at your call site instead of a bug at runtime.
292
+ */
293
+ export interface PlaybackSnapshotState {
294
+ readonly active_device_id: string | null;
295
+ /** v1 shim, dies with the column-prune migration. */
296
+ readonly active_session_id: string | null;
297
+ /** A NUMBER, not a string. See the note on this interface. */
298
+ readonly song_id: number | null;
299
+ readonly position: number;
300
+ readonly paused: boolean;
301
+ /** Song ids as strings, because the channel stringifies them on write. */
302
+ readonly queue: readonly string[];
303
+ readonly queue_index: number;
304
+ /** Indices into `queue`, the shuffle permutation. */
305
+ readonly queue_order: readonly number[];
306
+ readonly loop_mode: "none" | "one" | "all";
307
+ readonly shuffle: boolean;
308
+ /**
309
+ * The ACTIVE DEVICE's own output level. It rides the state so it survives a
310
+ * reload, but it is explicitly not adopted on a takeover - a phone at 20%
311
+ * must not set a laptop to 20%.
312
+ */
313
+ readonly volume: number;
314
+ readonly playback_rate: number;
315
+ readonly playback_mode: "original" | "instrumental" | "vocals" | "custom";
316
+ readonly eq_low: number;
317
+ readonly eq_mid: number;
318
+ readonly eq_high: number;
319
+ readonly eq_enabled: boolean;
320
+ readonly separation_enabled: boolean;
321
+ readonly vocal_volume: number;
322
+ readonly instrumental_volume: number;
323
+ /**
324
+ * The full blueprint of every queued song - by far the heaviest part of the
325
+ * payload, and OMITTED whenever the queue itself did not change.
326
+ *
327
+ * This is the single most misread field on the cable. `state_changed` for a
328
+ * volume drag carries a complete `state` with NO `queue_songs`; a client that
329
+ * replaces its local state wholesale empties its own queue view on every
330
+ * pause. Merge: take the new state, and keep the last `queue_songs` you saw
331
+ * whenever this is `undefined`.
332
+ */
333
+ readonly queue_songs?: readonly unknown[];
334
+ }
335
+ /** One entry of the device picker. Online and offline rows differ in shape. */
336
+ export interface PlaybackDeviceEntry {
337
+ /**
338
+ * `"<session_id>:<tab_uuid>"` for an online device, a bare session id for an
339
+ * offline one. Never the raw `device_id` you sent: the server composes it
340
+ * from your session so one session cannot claim another's device.
341
+ */
342
+ readonly id: string;
343
+ readonly label: string | null;
344
+ readonly device_type: string | null;
345
+ /** Offline rows only. */
346
+ readonly description?: string;
347
+ /** Online rows only. */
348
+ readonly last_seen_at?: string;
349
+ /** Offline rows only. */
350
+ readonly last_used_at?: string;
351
+ /**
352
+ * `false` marks a recently used session with no live socket. Those rows are
353
+ * DISPLAY ONLY - `transfer` to one answers `device_offline`.
354
+ */
355
+ readonly online: boolean;
356
+ }
357
+ /** Everything `PlaybackChannel` streams, discriminated on `type`. */
358
+ export type PlaybackMessage = {
359
+ readonly type: "snapshot";
360
+ /** Always `2` from a current server. v1 clients saw no `v` at all. */
361
+ readonly v: number;
362
+ readonly state: PlaybackSnapshotState;
363
+ readonly devices: readonly PlaybackDeviceEntry[];
364
+ readonly active_device_id: string | null;
365
+ /** The composed id of THIS subscription, or null for a v1 subscribe. */
366
+ readonly your_device_id: string | null;
367
+ readonly active_session_id: string | null;
368
+ readonly your_session_id: string;
369
+ } | {
370
+ readonly type: "state_changed";
371
+ readonly state: PlaybackSnapshotState;
372
+ readonly active_device_id: string | null;
373
+ /** Null when the change came from the server (a reap, an adoption). */
374
+ readonly from_device_id: string | null;
375
+ readonly from_session_id: string | null;
376
+ } | {
377
+ readonly type: "position_tick";
378
+ readonly position: number;
379
+ readonly paused: boolean;
380
+ /**
381
+ * A STRING here, unlike `state.song_id`, because the channel re-emits
382
+ * what the publisher sent after a `\A\d{1,18}\z` match. `null` when the
383
+ * publisher omitted it or sent something that did not match.
384
+ *
385
+ * It exists so a controller can DROP a tick describing a song it no
386
+ * longer shows; without that check the old position flashes across every
387
+ * track change.
388
+ */
389
+ readonly song_id: string | null;
390
+ /** Server clock in epoch ms, for estimating drift. */
391
+ readonly server_time: number;
392
+ readonly from_device_id: string | null;
393
+ readonly from_session_id: string | null;
394
+ } | {
395
+ readonly type: "devices_changed";
396
+ readonly devices: readonly PlaybackDeviceEntry[];
397
+ readonly active_device_id: string | null;
398
+ readonly active_session_id: string | null;
399
+ } | {
400
+ readonly type: "command";
401
+ readonly command: PlaybackCommandName;
402
+ readonly args: Record<string, unknown>;
403
+ /**
404
+ * Execute ONLY when this is your own device id. The command is broadcast
405
+ * to the whole user stream, so every one of that user's tabs sees every
406
+ * remote control press; obeying one addressed to another device is how
407
+ * two devices end up fighting over the same queue.
408
+ */
409
+ readonly target_device_id: string | null;
410
+ readonly target_session_id: string | null;
411
+ readonly from_device_id: string | null;
412
+ readonly from_session_id: string | null;
413
+ } | {
414
+ /** A `claim_active` with `mode: "if_none"` lost the race. Stay passive. */
415
+ readonly type: "claim_rejected";
416
+ readonly active_device_id: string | null;
417
+ readonly active_session_id: string | null;
418
+ } | {
419
+ /** Sent to the SENDER only: a command was issued with nothing to run it. */
420
+ readonly type: "no_active_device";
421
+ readonly active_device_id: null;
422
+ } | {
423
+ /** The active device could not autoplay. Surface "tap to resume". */
424
+ readonly type: "activation_blocked";
425
+ readonly device_id: string | null;
426
+ } | {
427
+ /**
428
+ * Sent to the SENDER only. `reason` is one of `not_active_device`,
429
+ * `unknown_command`, `invalid_args`, `payload_too_large`,
430
+ * `device_offline`, `queue_truncated`, or a model validation string.
431
+ *
432
+ * The right response to any of them is `request_snapshot`: an error means
433
+ * your idea of the state and the server's have diverged, and every one of
434
+ * these was raised while REFUSING a write, so nothing was broadcast to
435
+ * put you back in sync.
436
+ */
437
+ readonly type: "error";
438
+ readonly action: string;
439
+ readonly reason: string;
440
+ };
441
+ /**
442
+ * The remote-control vocabulary, exactly as `COMMAND_SCHEMAS` lists it.
443
+ *
444
+ * A name outside this set is answered with `error: unknown_command` and never
445
+ * reaches a broadcast. `jam_add_song` is missing on purpose: it is server-built
446
+ * in `JamsController` so a client cannot forge a jam proposal.
447
+ */
448
+ export type PlaybackCommandName = "play" | "pause" | "toggle" | "next" | "previous" | "seek" | "set_queue_index" | "set_queue_order" | "set_shuffle" | "set_loop_mode" | "set_volume" | "add_to_queue" | "play_next" | "remove_from_queue" | "reorder_queue";
449
+ /** Subscription params for `PlaybackChannel`. */
450
+ export interface PlaybackSubscribeParams {
451
+ /**
452
+ * A per-tab, per-launch opaque token matching `[A-Za-z0-9-]{8,64}` - a
453
+ * `crypto.randomUUID()` is the intended shape.
454
+ *
455
+ * The server prefixes it with your session id, so the id you see on the wire
456
+ * is `"<session_id>:<this>"` and impersonating another session is impossible
457
+ * by construction. An invalid shape does not error: it is treated as a v1
458
+ * client with no device at all, which then fails every publisher action with
459
+ * `not_active_device`. Sending nothing at all is the same thing.
460
+ */
461
+ readonly device_id?: string;
462
+ /** Free text, truncated to 80 chars. Defaults to the session's name. */
463
+ readonly device_label?: string;
464
+ /**
465
+ * The previous tab uuid of a reloading page.
466
+ *
467
+ * Without it, a reload leaves the old tab "online" until the 75 s registry
468
+ * TTL expires, and if that tab was the active device every other device shows
469
+ * "playing elsewhere" until then. With it, the ghost dies immediately and
470
+ * activeness follows to the reborn tab (paused - the reload stopped audio).
471
+ */
472
+ readonly predecessor?: string;
473
+ }
474
+ /**
475
+ * A `PlaybackChannel` subscription, with the eight client actions named.
476
+ *
477
+ * `perform` is still there for anything added server-side before this file
478
+ * catches up, but every action the channel defines today has a method, because
479
+ * the argument shapes are validated strictly and a typo in an action name is
480
+ * silently ignored by ActionCable rather than reported.
481
+ */
482
+ export interface PlaybackSubscription extends CableSubscription {
483
+ /**
484
+ * Keeps the registry row alive. Send it every 20 s: the row's TTL is 75 s
485
+ * and a device that stops beating drops off every picker.
486
+ */
487
+ heartbeat(): void;
488
+ /**
489
+ * Asks for a fresh `snapshot`, transmitted to this subscriber only.
490
+ *
491
+ * Send it after any cable `error`, and on every app foreground: a socket that
492
+ * survived a background suspension is not a socket whose state is current.
493
+ */
494
+ requestSnapshot(): void;
495
+ /**
496
+ * Becomes the active device.
497
+ *
498
+ * `"if_none"` (the default) is a compare-and-set - it claims only if nothing
499
+ * is active, and the loser gets `claim_rejected` and must stay passive.
500
+ * `"steal"` is an unconditional takeover, which is what pressing play on a
501
+ * second device means everywhere else and what starting a jam requires.
502
+ */
503
+ claimActive(mode?: "if_none" | "steal"): void;
504
+ /**
505
+ * Hands playback to another device.
506
+ *
507
+ * Only a device that is ONLINE right now can receive it; an offline picker
508
+ * row answers `error: device_offline`. `target_session_id` is the v1 shim and
509
+ * is mapped to that session's newest online device when there is one.
510
+ */
511
+ transfer(target: {
512
+ device_id?: string;
513
+ session_id?: string;
514
+ }): void;
515
+ /**
516
+ * Sends a remote-control command to whichever device is active.
517
+ *
518
+ * The whole frame is capped at 8 KiB. Args are schema-checked per command and
519
+ * a mismatch is answered with `invalid_args` rather than clamped - unlike
520
+ * `state_changed`, which clamps almost everything. `song_id` args must be a
521
+ * STRING of digits.
522
+ */
523
+ command(command: PlaybackCommandName, args?: Record<string, unknown>): void;
524
+ /**
525
+ * Publishes a partial state. **Active device only** - anyone else gets
526
+ * `error: not_active_device` and nothing is written.
527
+ *
528
+ * Debounce it to ~200 ms. The server clamps rather than rejects (queue to
529
+ * 1000 entries, rate to 0.25-4, EQ to +/-12 dB, volumes to 0-1) and silently
530
+ * STRIPS song ids you do not own, remapping `queue_order` and `queue_index`
531
+ * around the holes, so what comes back may not be what you sent. Only a
532
+ * truncation is reported, as `error: queue_truncated`.
533
+ */
534
+ publishState(payload: Record<string, unknown>): void;
535
+ /**
536
+ * The 1 Hz position heartbeat. **Active device only.**
537
+ *
538
+ * Cheap by design: the server persists at most every 5 s and broadcasts the
539
+ * rest straight through. Send `song_id` as a digit STRING so listeners can
540
+ * discard ticks from a track they have already left.
541
+ */
542
+ positionTick(tick: {
543
+ position: number;
544
+ paused: boolean;
545
+ song_id?: string | number | null;
546
+ }): void;
547
+ /**
548
+ * Announces that autoplay was refused here. **Active device only.** Fans out
549
+ * so every device can show the needs-a-tap hint.
550
+ */
551
+ activationBlocked(): void;
552
+ }
553
+ /**
554
+ * Everything `JamChannel` streams.
555
+ *
556
+ * The channel is RECEIVE-ONLY: it defines no client actions at all, and every
557
+ * jam mutation is a REST call on `/jams`. `perform` on a jam subscription
558
+ * therefore does nothing useful, and the SDK does not offer a typed action for
559
+ * it.
560
+ */
561
+ export type JamMessage = {
562
+ readonly type: "snapshot";
563
+ readonly jam: unknown;
564
+ readonly state: unknown;
565
+ } | {
566
+ readonly type: "state_changed";
567
+ readonly state: unknown;
568
+ } | {
569
+ readonly type: "position_tick";
570
+ readonly position: number;
571
+ readonly paused: boolean;
572
+ /** A string, same as the playback tick. Correlate, do not trust ordering. */
573
+ readonly song_id: string | null;
574
+ readonly server_time: number;
575
+ } | {
576
+ readonly type: "members_changed";
577
+ readonly jam: unknown;
578
+ } | {
579
+ readonly type: "jam_updated";
580
+ readonly jam: unknown;
581
+ } | {
582
+ readonly type: "song_proposed";
583
+ /** `song.id` is an INTEGER here - this payload is built by the controller. */
584
+ readonly song: {
585
+ readonly id: number;
586
+ readonly title: string;
587
+ readonly artist_names: string;
588
+ };
589
+ readonly proposer: {
590
+ readonly id: string;
591
+ readonly handle: string;
592
+ readonly name: string;
593
+ };
594
+ } | {
595
+ readonly type: "skip_votes";
596
+ /** The host's `PlaybackState#song_id`: a NUMBER, not a string. */
597
+ readonly song_id: number | null;
598
+ readonly count: number;
599
+ readonly needed: number;
600
+ } | {
601
+ readonly type: "skipped";
602
+ } | {
603
+ readonly type: "ended";
604
+ };
605
+ /** One friend's row in the listening feed. */
606
+ export interface FriendListening {
607
+ readonly user: {
608
+ readonly id: string;
609
+ readonly handle: string;
610
+ readonly name: string;
611
+ };
612
+ /**
613
+ * `null` when that friend turned sharing off. Presence and jam stay visible
614
+ * either way, because joining a jam is an explicit social act and passive
615
+ * listening data is not. `song.id` is an INTEGER.
616
+ */
617
+ readonly song: {
618
+ readonly id: number;
619
+ readonly title: string;
620
+ readonly album: string | null;
621
+ readonly duration: number | null;
622
+ readonly owner_id: string;
623
+ readonly artist_names: string;
624
+ readonly artwork_url: string | null;
625
+ } | null;
626
+ readonly paused: boolean;
627
+ /** A device of theirs was seen within 75 s. */
628
+ readonly online: boolean;
629
+ readonly jam_id: number | null;
630
+ readonly updated_at: string | null;
631
+ }
632
+ /**
633
+ * Everything `FriendListeningChannel` streams.
634
+ *
635
+ * `listening_update` is NOT wrapped: the snapshot nests its rows under
636
+ * `friends`, but an update spreads one row's fields at the TOP LEVEL beside
637
+ * `type`. Replace the row whose `user.id` matches, append when it is new.
638
+ */
639
+ export type FriendListeningMessage = {
640
+ readonly type: "snapshot";
641
+ readonly friends: readonly FriendListening[];
642
+ } | ({
643
+ readonly type: "listening_update";
644
+ } & FriendListening);
645
+ /** Everything `JobChannel` streams. */
646
+ export interface JobMessage {
647
+ /** Always `"snapshot"`, on subscribe and on every change alike. */
648
+ readonly type: "snapshot";
649
+ /** The `:extended` job blueprint. Finished when `finished_at` is non-null. */
650
+ readonly job: unknown;
651
+ }
652
+ /** Subscription params for `JobChannel`. */
653
+ export interface JobSubscribeParams {
654
+ /** The job id. A STRING - jobs are among the string-id models. */
655
+ readonly id: string;
656
+ /**
657
+ * A watch token minted at enqueue time, for a caller with no session.
658
+ *
659
+ * This is the one channel an anonymous connection can legitimately use: the
660
+ * captcha-gated tools hand an anonymous user a signed token so they can watch
661
+ * their own job. The token is checked to name THIS job id, so it is not a
662
+ * general read capability.
663
+ */
664
+ readonly token?: string;
665
+ }
666
+ /** Everything `NotificationsChannel` streams. */
667
+ export type NotificationsMessage = {
668
+ /** Sent on subscribe, and again whenever a read flag or a delete moves it. */
669
+ readonly type: "unread_count";
670
+ readonly unread_count: number;
671
+ } | {
672
+ readonly type: "created";
673
+ /** The `:extended` notification blueprint. */
674
+ readonly notification: unknown;
675
+ readonly unread_count: number;
676
+ };
677
+ /**
678
+ * One multiplexed cable connection.
679
+ *
680
+ * All five channels share a single WebSocket, which is what ActionCable is for:
681
+ * the `identifier` is the demultiplexing key. Open one of these per identity
682
+ * and keep it for the lifetime of the session.
683
+ *
684
+ * ## What a reconnect does to your subscriptions
685
+ *
686
+ * Nothing you have to undo, and one thing you have to expect.
687
+ *
688
+ * The registry of subscriptions lives on the CONNECTION, not on the socket. A
689
+ * drop clears the welcomed flag, fires {@link CableHandlers.onDisconnect} on
690
+ * every live subscription, and schedules a reopen; the identifiers stay in the
691
+ * map. When the new socket says `welcome`, EVERY identifier still in that map
692
+ * is re-subscribed, in insertion order, and each one confirms again through
693
+ * {@link CableHandlers.onConfirm}. Your {@link CableSubscription} handle stays
694
+ * valid across all of it - you never re-subscribe by hand and you must not, or
695
+ * you will end up with the same identifier registered twice.
696
+ *
697
+ * The thing to expect is that server-side subscribe-time work RUNS AGAIN,
698
+ * because `subscribed` runs again. Concretely: `PlaybackChannel` re-registers
699
+ * the device and re-broadcasts `devices_changed`, every channel re-transmits
700
+ * its snapshot, and `FriendListeningChannel` re-reads the friend roster. That
701
+ * last one is load-bearing in the other direction too - the roster is fixed at
702
+ * subscribe time, so a new friend or a privacy flip only appears after a
703
+ * resubscribe, which is why the app resubscribes it on foreground.
704
+ *
705
+ * The thing that does NOT happen is a replay of anything you sent. Frames sent
706
+ * while disconnected are dropped, not queued: see {@link CableSubscription.perform}.
707
+ *
708
+ * A rejection is remembered as a rejection only by your handler. The identifier
709
+ * stays in the map, so a reconnect re-subscribes it and it is rejected again.
710
+ * If a rejection means "give up" - and on this API it usually does, see trap 2 -
711
+ * call `unsubscribe()` from your `onReject`.
712
+ */
713
+ export interface CableConnection {
714
+ /** Current state. `"connected"` means welcomed. */
715
+ readonly state: CableState;
716
+ /** The URL of the current attempt, with the token redacted. For logs. */
717
+ readonly url: string;
718
+ /** How many identifiers are registered right now, live or rejected. */
719
+ readonly subscriptionCount: number;
720
+ /**
721
+ * Subscribes to any channel by its params object.
722
+ *
723
+ * The escape hatch, and the primitive the five typed helpers are built on.
724
+ * `channel` is required; every other key becomes a subscription param. Key
725
+ * insertion order becomes the identifier's byte order, and the SDK never
726
+ * re-serialises it, so any order works as long as it is yours.
727
+ */
728
+ channel<TMessage = unknown>(params: {
729
+ channel: string;
730
+ } & Record<string, unknown>, handlers: CableHandlers<TMessage>): CableSubscription;
731
+ /**
732
+ * `PlaybackChannel` - remote playback, device presence, and the host half of
733
+ * a jam. Rejected for an anonymous connection, and rejected outright when
734
+ * `device_id` is present but malformed.
735
+ */
736
+ playback(params: PlaybackSubscribeParams, handlers: CableHandlers<PlaybackMessage>): PlaybackSubscription;
737
+ /**
738
+ * `JamChannel` - receive-only. JOIN OVER REST FIRST: the channel rejects
739
+ * anyone who is not already a member of an ACTIVE jam, and a rejection
740
+ * arriving mid-jam means the jam is gone. Clear your state; do not retry.
741
+ */
742
+ jam(jamId: number, handlers: CableHandlers<JamMessage>): CableSubscription;
743
+ /**
744
+ * `FriendListeningChannel` - the friends-are-listening feed. Takes no params.
745
+ * The roster is captured at subscribe time; resubscribe to refresh it.
746
+ */
747
+ friendListening(handlers: CableHandlers<FriendListeningMessage>): CableSubscription;
748
+ /**
749
+ * `JobChannel` - one job's progress, replacing a poll loop. Pair it with a
750
+ * slow REST poll (~10 s) anyway: a job that finished before you subscribed
751
+ * still sends its snapshot, but a socket that dies mid-job does not.
752
+ */
753
+ job(params: JobSubscribeParams, handlers: CableHandlers<JobMessage>): CableSubscription;
754
+ /** `NotificationsChannel` - one per-user stream for every signed-in device. */
755
+ notifications(handlers: CableHandlers<NotificationsMessage>): CableSubscription;
756
+ /**
757
+ * Closes the socket and stops reconnecting. Subscriptions are dropped.
758
+ * Idempotent; the connection cannot be reopened - build a new one.
759
+ */
760
+ close(): void;
761
+ }
762
+ /**
763
+ * Turns the API root into the cable endpoint.
764
+ *
765
+ * `https://backend.omelhorsite.pt` becomes
766
+ * `wss://backend.omelhorsite.pt/cable`. The scheme swap is a prefix rewrite
767
+ * rather than a `URL` round trip on purpose: `URL` is not universally available
768
+ * on the runtimes this SDK targets, and the only two schemes the API is ever
769
+ * served under are `http` and `https`.
770
+ */
771
+ export declare function cableEndpoint(baseUrl: string, path?: string): string;
772
+ /**
773
+ * Appends the credential as `?token=`, or returns the bare URL.
774
+ *
775
+ * The token goes in the query and NOWHERE else. A browser cannot put a header
776
+ * on a WebSocket handshake at all, and on the runtimes that can, doing so would
777
+ * hit trap 1: `Session.token_from_request` takes the first non-blank candidate,
778
+ * header first, so any header at all - including a stale one - decides the
779
+ * identity and the query param is never consulted.
780
+ *
781
+ * An empty string is treated as no credential, which is the cookie-auth case:
782
+ * the handshake goes out bare and the browser attaches the httpOnly session
783
+ * cookie itself. That only works same-site.
784
+ */
785
+ export declare function handshakeUrl(endpoint: string, token: string | null | undefined): string;
786
+ /**
787
+ * The `realtime` namespace, reachable as `oms.realtime`.
788
+ *
789
+ * It holds no socket of its own. {@link connect} builds one and hands it back,
790
+ * so a host that needs two identities (a signed-in user and an anonymous job
791
+ * watcher) gets two connections rather than a hidden singleton it cannot
792
+ * separate. That is the one place this differs from both existing
793
+ * implementations, which each keep a module-level singleton - fine for one app,
794
+ * wrong for a library.
795
+ *
796
+ * ## Why the credential is passed in and not taken from the client
797
+ *
798
+ * `Oms` already holds a credential, and this namespace deliberately does not
799
+ * reach into it. Two reasons, both from the Rails source rather than from
800
+ * taste:
801
+ *
802
+ * - the cable resolves ONLY `Session` tokens (`Session.find_by(token:)`), so
803
+ * the OAuth access token an `Oms` may be carrying is not a cable credential
804
+ * at all - silently reusing it would hand you an anonymous connection;
805
+ * - `sessionCookie: true` clients have no token to reuse, and their handshake
806
+ * needs no `?token=` because the browser sends the cookie. For those, pass
807
+ * nothing.
808
+ *
809
+ * ## Rate limits
810
+ *
811
+ * None. `/cable` is on rack-attack's allowlist, so neither the 600/min authed
812
+ * ceiling nor the 120/min anonymous one applies to the handshake or to any
813
+ * frame after it. The backoff in {@link CableConnectOptions.reconnectMaxMs} is
814
+ * therefore the only thing standing between a restarting server and a
815
+ * reconnect storm; it is not decorative.
816
+ */
817
+ export declare class RealtimeNamespace extends Resource {
818
+ /**
819
+ * Opens a cable connection. Connecting starts immediately.
820
+ *
821
+ * The returned {@link CableConnection} is usable before it is welcomed:
822
+ * subscriptions made now are registered and sent the moment `welcome`
823
+ * arrives, which is the normal way to use it - there is no "wait until
824
+ * connected" step and you should not build one.
825
+ *
826
+ * ```ts
827
+ * const cable = oms.realtime.connect({
828
+ * token: sessionToken, // a Session token, NOT an OAuth one
829
+ * socket: (url) => new WebSocket(url),
830
+ * onStateChange: (s) => setBadge(s),
831
+ * });
832
+ *
833
+ * const playback = cable.playback(
834
+ * { device_id: crypto.randomUUID(), device_label: "Laptop" },
835
+ * {
836
+ * onMessage: (msg) => {
837
+ * if (msg.type === "snapshot") hydrate(msg.state);
838
+ * if (msg.type === "error") playback.requestSnapshot();
839
+ * },
840
+ * onReject: () => signOut(), // anonymous connection: see trap 2
841
+ * onConfirm: () => playback.claimActive("if_none"),
842
+ * },
843
+ * );
844
+ * ```
845
+ */
846
+ connect(options?: CableConnectOptions): CableConnection;
847
+ /**
848
+ * The cable URL this client would use, without a credential.
849
+ *
850
+ * Useful for a host that wants to open the socket itself (an Expo background
851
+ * task, a Worker Durable Object) and only needs the SDK to agree with it on
852
+ * where `/cable` lives.
853
+ */
854
+ endpoint(path?: string): string;
855
+ }