@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.
- package/dist/index.js +4939 -552
- package/dist/types/client.d.ts +60 -3
- package/dist/types/http.d.ts +444 -19
- package/dist/types/index.d.ts +4 -1
- package/dist/types/resources/account.d.ts +66 -3
- package/dist/types/resources/admin.d.ts +1837 -0
- package/dist/types/resources/auth/index.d.ts +39 -0
- package/dist/types/resources/auth/passkeys.d.ts +652 -0
- package/dist/types/resources/auth/sessions.d.ts +847 -0
- package/dist/types/resources/chests.d.ts +54 -3
- package/dist/types/resources/content.d.ts +2970 -0
- package/dist/types/resources/dynamicQrs.d.ts +39 -3
- package/dist/types/resources/forms.d.ts +176 -35
- package/dist/types/resources/index.d.ts +19 -8
- package/dist/types/resources/ipLookup.d.ts +20 -4
- package/dist/types/resources/jobs.d.ts +62 -21
- package/dist/types/resources/library.d.ts +1435 -0
- package/dist/types/resources/linkTrees.d.ts +142 -30
- package/dist/types/resources/media.d.ts +351 -0
- package/dist/types/resources/movies.d.ts +1186 -0
- package/dist/types/resources/music/artists.d.ts +1066 -0
- package/dist/types/resources/music/imports.d.ts +940 -0
- package/dist/types/resources/music/index.d.ts +61 -0
- package/dist/types/resources/music/playlists.d.ts +1026 -0
- package/dist/types/resources/music/social.d.ts +1132 -0
- package/dist/types/resources/music/songs.d.ts +1183 -0
- package/dist/types/resources/notepads.d.ts +4 -1
- package/dist/types/resources/quotas.d.ts +7 -1
- package/dist/types/resources/realtime.d.ts +855 -0
- package/dist/types/resources/shortLinks.d.ts +45 -4
- package/dist/types/resources/social.d.ts +1330 -0
- package/dist/types/resources/storage/upload.d.ts +158 -11
- package/dist/types/resources/storage.d.ts +88 -22
- package/dist/types/resources/tickets.d.ts +82 -3
- package/dist/types/resources/tools/backgroundRemoval.d.ts +18 -3
- package/dist/types/resources/tools/captions.d.ts +448 -21
- package/dist/types/resources/tools/downloader.d.ts +21 -0
- package/dist/types/resources/tools/index.d.ts +57 -15
- package/dist/types/resources/tools/jumpstyle.d.ts +50 -17
- package/dist/types/resources/tools/transcription.d.ts +35 -13
- package/dist/types/resources/tools/upscale.d.ts +23 -3
- package/dist/types/resources/tools/vocalSeparation.d.ts +30 -13
- package/dist/types/types.d.ts +249 -17
- 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
|
+
}
|