@single-studio/plugin-obs 0.6.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Shaun Delaney
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @single-studio/plugin-obs
2
+
3
+ OBS for [Single Studio](https://fourcourtjester.github.io/Single-Studio/): the live
4
+ scene, stream and record state, straight into a studio over obs-websocket.
5
+
6
+ ```bash
7
+ npm i @single-studio/plugin-obs
8
+ ```
9
+
10
+ ```js
11
+ // src/studio/velcro.worker.js
12
+ import { obs, OBSHandler } from '@single-studio/plugin-obs'
13
+
14
+ class MyShow extends OBSHandler {
15
+ onScene({ name }) {
16
+ this.mutate('set', { 'variables.scene': name })
17
+ }
18
+ }
19
+
20
+ createVelcroHost({ name: STUDIO_ID, mutations, plugins: [obs(MyShow)] })
21
+ ```
22
+
23
+ The operator sets the host, port and password on their own board, under **Plugins** —
24
+ those belong to the machine, not to the build. OBS: **Tools → WebSocket Server
25
+ Settings**.
26
+
27
+ ## What it tells you
28
+
29
+ | Method | When |
30
+ | --- | --- |
31
+ | `onConnected` | the connection is up and identified |
32
+ | `onScene` | the programme scene changed |
33
+ | `onPreview` | the preview scene changed (studio mode) |
34
+ | `onSourceVisibility` | a source was shown or hidden |
35
+ | `onStream` / `onRecord` | streaming or recording started or stopped |
36
+ | `onMute` | an audio input was muted or unmuted |
37
+ | `onTransitionStarted` / `onTransitionEnded` | a transition ran |
38
+ | `onExit` | OBS is shutting down |
39
+
40
+ ## What you can ask it
41
+
42
+ ```js
43
+ this.command('scene', { name: 'Podium' })
44
+ ```
45
+
46
+ And from anywhere else in a studio — a mutation, another plugin's handler:
47
+
48
+ ```js
49
+ ctx.ask('obs', 'scene', { name: 'Podium' })
50
+ ```
51
+
52
+ `ask` when you want the answer back rather than fire-and-forget:
53
+
54
+ ```js
55
+ const { sceneItemId } = await this.look('obs', 'GetSceneItemId', { sceneName: 'Match', sourceName: 'cam' })
56
+ ```
57
+
58
+ That one exists because OBS assigns `sceneItemId` when a source is added to a scene
59
+ and changes it if the source is removed and put back — so it cannot be written down
60
+ anywhere and has to be looked up from the name somebody typed.
61
+
62
+ ## Licence
63
+
64
+ MIT.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@single-studio/plugin-obs",
3
+ "version": "0.6.0",
4
+ "description": "OBS for Single Studio: the live scene, stream and record state, straight into a studio over obs-websocket.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FourCourtJester/Single-Studio.git",
9
+ "directory": "packages/plugin-obs"
10
+ },
11
+ "homepage": "https://github.com/FourCourtJester/Single-Studio#readme",
12
+ "bugs": "https://github.com/FourCourtJester/Single-Studio/issues",
13
+ "type": "module",
14
+ "keywords": [
15
+ "single-studio",
16
+ "single-studio-plugin",
17
+ "obs",
18
+ "broadcast"
19
+ ],
20
+ "exports": {
21
+ ".": "./src/index.js"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "vitest run"
30
+ },
31
+ "peerDependencies": {
32
+ "@single-studio/core": "^0.6.0"
33
+ },
34
+ "devDependencies": {
35
+ "@single-studio/core": "workspace:*",
36
+ "vitest": "^3.2.6"
37
+ }
38
+ }
package/src/events.js ADDED
@@ -0,0 +1,92 @@
1
+ // OBS's events, in the shape a studio would have written.
2
+ //
3
+ // Field names come from obs-websocket's own protocol reference. The reshaping is
4
+ // small on purpose -- OBS is already close to readable -- and is mostly about two
5
+ // things: dropping the UUIDs nobody addresses anything by, and turning
6
+ // `outputActive` plus `outputState` into the one word a graphic reads.
7
+
8
+ /**
9
+ * `outputState` is a string like `OBS_WEBSOCKET_OUTPUT_STARTING`, which is precise
10
+ * and not something to put in a template. `outputActive` alone loses the difference
11
+ * between starting and started, and a "LIVE" badge that lights up during the
12
+ * handshake is on air before the stream is.
13
+ */
14
+ const stateOf = (data) => {
15
+ const raw = String(data?.outputState ?? '')
16
+ const word = raw.replace('OBS_WEBSOCKET_OUTPUT_', '').toLowerCase()
17
+
18
+ return {
19
+ active: Boolean(data?.outputActive),
20
+ state: word || (data?.outputActive ? 'started' : 'stopped'),
21
+ // The two that matter for a badge: only `started` is genuinely on air.
22
+ live: word === 'started',
23
+ settling: word === 'starting' || word === 'stopping',
24
+ }
25
+ }
26
+
27
+ export const EVENTS = {
28
+ CurrentProgramSceneChanged: {
29
+ emit: 'scene',
30
+ category: 'scenes',
31
+ shape: (data) => ({ name: data?.sceneName ?? null }),
32
+ },
33
+ CurrentPreviewSceneChanged: {
34
+ emit: 'preview',
35
+ category: 'scenes',
36
+ shape: (data) => ({ name: data?.sceneName ?? null }),
37
+ },
38
+ SceneItemEnableStateChanged: {
39
+ emit: 'sourceVisibility',
40
+ category: 'sceneItems',
41
+ shape: (data) => ({ scene: data?.sceneName ?? null, itemId: data?.sceneItemId ?? null, visible: Boolean(data?.sceneItemEnabled) }),
42
+ },
43
+ StreamStateChanged: {
44
+ emit: 'stream',
45
+ category: 'outputs',
46
+ shape: stateOf,
47
+ },
48
+ RecordStateChanged: {
49
+ emit: 'record',
50
+ category: 'outputs',
51
+ shape: (data) => ({ ...stateOf(data), path: data?.outputPath ?? null }),
52
+ },
53
+ InputMuteStateChanged: {
54
+ emit: 'mute',
55
+ category: 'inputs',
56
+ shape: (data) => ({ input: data?.inputName ?? null, muted: Boolean(data?.inputMuted) }),
57
+ },
58
+ SceneTransitionStarted: {
59
+ emit: 'transitionStarted',
60
+ category: 'transitions',
61
+ shape: (data) => ({ transition: data?.transitionName ?? null }),
62
+ },
63
+ SceneTransitionEnded: {
64
+ emit: 'transitionEnded',
65
+ category: 'transitions',
66
+ shape: (data) => ({ transition: data?.transitionName ?? null }),
67
+ },
68
+ ExitStarted: {
69
+ emit: 'exit',
70
+ category: 'general',
71
+ shape: () => ({}),
72
+ },
73
+ }
74
+
75
+ /** The categories the wanted events live in, as one bitmask's worth of names. */
76
+ export const categoriesFor = (types = Object.keys(EVENTS)) => [...new Set(types.map((type) => EVENTS[type]?.category).filter(Boolean))]
77
+
78
+ /**
79
+ * One OBS event, as the event a studio hears.
80
+ *
81
+ * Unknown types pass through under their own name with the payload untouched, so a
82
+ * studio wanting something this table has not caught up with is not blocked on a
83
+ * release.
84
+ */
85
+ export function normalise(type, data) {
86
+ const known = EVENTS[type]
87
+
88
+ return {
89
+ name: known?.emit ?? type,
90
+ payload: { ...(known ? known.shape(data) : {}), raw: data },
91
+ }
92
+ }
package/src/index.js ADDED
@@ -0,0 +1,256 @@
1
+ import { definePlugin, PluginHandler, SocketService } from '@single-studio/core/worker'
2
+
3
+ import { categoriesFor, EVENTS, normalise } from './events.js'
4
+ import { authenticate, classify, identify, maskOf, request } from './protocol.js'
5
+
6
+ export { CATEGORY, OP, authenticate, classify, maskOf } from './protocol.js'
7
+ export { EVENTS, categoriesFor, normalise } from './events.js'
8
+
9
+ /**
10
+ * OBS, over obs-websocket, in the SharedWorker.
11
+ *
12
+ * The one every studio wants and the one nobody thinks of first, because OBS is
13
+ * where the studio already lives. Knowing which scene is live lets a graphic decide
14
+ * for itself: a lower third that hides when the camera cuts away, a scoreboard that
15
+ * only counts while the match scene is up, a "LIVE" badge that is honest.
16
+ *
17
+ * Requests were always here, because reading needs them: OBS announces changes and
18
+ * never announces the present, so a studio that only listened would not know the
19
+ * scene until somebody changed it. The plugin asks once on connect.
20
+ *
21
+ * A handful of them are now declared as commands, so a studio can answer as well as
22
+ * listen -- cut the scene when the whistle goes. What is still deferred is a
23
+ * *remote* operator pressing a button, which is a routing problem rather than a
24
+ * protocol one. See architecture.md#commands.
25
+ */
26
+ class OBS extends SocketService {
27
+ static serviceName = 'obs'
28
+
29
+ /**
30
+ * What a studio can ask OBS to do.
31
+ *
32
+ * A deliberately small list: the things a show does *to itself* while it is
33
+ * running. OBS accepts a hundred-odd requests and most of them configure it --
34
+ * creating scenes, moving sources, setting up encoders -- which is the operator's
35
+ * job at build time and not something a graphic should be doing at five to seven.
36
+ *
37
+ * `requestId` is a constant rather than a counter because nothing waits for the
38
+ * reply. `ask` correlates because it has a promise to settle; a command is told,
39
+ * not asked, and OBS ignores an id it is not being asked about.
40
+ */
41
+ static commands = {
42
+ /** Cut to a scene by name. The one everybody wants. */
43
+ scene: ({ name }) => request('SetCurrentProgramScene', 'ss-cmd', { sceneName: name }),
44
+
45
+ /** Load a scene into preview, for a studio-mode operator to take manually. */
46
+ preview: ({ name }) => request('SetCurrentPreviewScene', 'ss-cmd', { sceneName: name }),
47
+
48
+ stream: ({ on }) => request(on ? 'StartStream' : 'StopStream', 'ss-cmd'),
49
+ record: ({ on }) => request(on ? 'StartRecord' : 'StopRecord', 'ss-cmd'),
50
+ pauseRecord: ({ on }) => request(on ? 'PauseRecord' : 'ResumeRecord', 'ss-cmd'),
51
+
52
+ /** Show or hide one source within a scene. Needs the scene's item id. */
53
+ item: ({ scene, id, visible }) => request('SetSceneItemEnabled', 'ss-cmd', { sceneName: scene, sceneItemId: id, sceneItemEnabled: Boolean(visible) }),
54
+ }
55
+
56
+ #pending = new Map()
57
+
58
+ #nextId = 0
59
+
60
+ /** Not usable until identified, so `open()` waits for that rather than the socket. */
61
+ get readyOnOpen() {
62
+ return false
63
+ }
64
+
65
+ get types() {
66
+ const asked = String(this.config.events ?? '')
67
+ .split(',')
68
+ .map((type) => type.trim())
69
+ .filter(Boolean)
70
+
71
+ return asked.length ? asked : Object.keys(EVENTS)
72
+ }
73
+
74
+ get url() {
75
+ const host = this.config.host || 'localhost'
76
+ const port = Number(this.config.port) || 4455
77
+
78
+ return `ws://${host}:${port}`
79
+ }
80
+
81
+ async receive(raw) {
82
+ const action = classify(raw)
83
+
84
+ switch (action.do) {
85
+ case 'identify': {
86
+ // A password is only needed when OBS asks. Sending one it did not ask for
87
+ // is refused, and refusing to connect because a studio left the field empty
88
+ // would be wrong for the common case of authentication switched off.
89
+ if (action.auth && !this.config.password) {
90
+ this.fail(new Error('OBS is asking for a password, and none is set.'))
91
+
92
+ return
93
+ }
94
+
95
+ const authentication = action.auth ? await authenticate(this.config.password, action.auth.salt, action.auth.challenge) : undefined
96
+
97
+ this.send(
98
+ identify({
99
+ rpcVersion: action.rpcVersion,
100
+ authentication,
101
+ eventSubscriptions: maskOf(categoriesFor(this.types)),
102
+ }),
103
+ )
104
+
105
+ return
106
+ }
107
+
108
+ case 'ready':
109
+ this.emit('connected', { rpcVersion: action.rpcVersion })
110
+
111
+ // OBS announces changes and never announces the present. Without this a
112
+ // studio does not know the scene until somebody changes it -- which on a
113
+ // quiet show could be the whole broadcast.
114
+ this.#prime().then(
115
+ () => this.ready(),
116
+ () => this.ready(),
117
+ )
118
+
119
+ return
120
+
121
+ case 'event': {
122
+ const { name, payload } = normalise(action.type, action.data)
123
+
124
+ this.emit(name, payload)
125
+ this.emit('*', name, payload)
126
+
127
+ return
128
+ }
129
+
130
+ case 'response': {
131
+ const settle = this.#pending.get(action.id)
132
+
133
+ this.#pending.delete(action.id)
134
+ settle?.(action)
135
+
136
+ return
137
+ }
138
+
139
+ default:
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Ask something and wait for the matching reply.
145
+ *
146
+ * Correlated by `requestId` rather than by order: OBS may answer out of order,
147
+ * and matching on arrival would attach one reply to another request's promise.
148
+ */
149
+ ask(requestType, requestData) {
150
+ const id = `ss-${(this.#nextId += 1)}`
151
+
152
+ return new Promise((resolve) => {
153
+ this.#pending.set(id, resolve)
154
+ this.send(request(requestType, id, requestData))
155
+ })
156
+ }
157
+
158
+ /** Read the present, once, so the first paint is not a guess. */
159
+ async #prime() {
160
+ const scene = await this.ask('GetCurrentProgramScene')
161
+
162
+ if (scene.ok) this.emit('scene', { name: scene.data.currentProgramSceneName ?? scene.data.sceneName ?? null, raw: scene.data })
163
+
164
+ const stream = await this.ask('GetStreamStatus')
165
+
166
+ if (stream.ok) {
167
+ this.emit('stream', {
168
+ active: Boolean(stream.data.outputActive),
169
+ state: stream.data.outputActive ? 'started' : 'stopped',
170
+ live: Boolean(stream.data.outputActive),
171
+ settling: false,
172
+ raw: stream.data,
173
+ })
174
+ }
175
+ }
176
+
177
+ async close() {
178
+ this.#pending.clear()
179
+ await super.close()
180
+ }
181
+ }
182
+
183
+ /** The skeleton a studio fills in. One method per event, all no-ops. */
184
+ export class OBSHandler extends PluginHandler {
185
+ static handles = {
186
+ connected: 'onConnected',
187
+ scene: 'onScene',
188
+ preview: 'onPreview',
189
+ sourceVisibility: 'onSourceVisibility',
190
+ stream: 'onStream',
191
+ record: 'onRecord',
192
+ mute: 'onMute',
193
+ transitionStarted: 'onTransitionStarted',
194
+ transitionEnded: 'onTransitionEnded',
195
+ exit: 'onExit',
196
+ }
197
+
198
+ onConnected() {}
199
+
200
+ onScene() {}
201
+
202
+ onPreview() {}
203
+
204
+ onSourceVisibility() {}
205
+
206
+ onStream() {}
207
+
208
+ onRecord() {}
209
+
210
+ onMute() {}
211
+
212
+ onTransitionStarted() {}
213
+
214
+ onTransitionEnded() {}
215
+
216
+ onExit() {}
217
+ }
218
+
219
+ /** @param {typeof OBSHandler} [Handler] */
220
+ export const obs = (Handler = OBSHandler) =>
221
+ definePlugin({
222
+ name: 'obs',
223
+ label: 'OBS',
224
+ summary: 'Reads the live scene, and whether you are streaming or recording.',
225
+ help: [
226
+ { type: 'text', text: 'OBS can already do this — there is nothing to install. It just has to be switched on.' },
227
+ {
228
+ type: 'steps',
229
+ items: [
230
+ 'In OBS, open Tools → WebSocket Server Settings.',
231
+ 'Tick "Enable WebSocket server".',
232
+ 'Leave the port as 4455 unless you have a reason to change it.',
233
+ 'If "Enable Authentication" is ticked, press "Show Connect Info" and copy the password into the field above. If it is not ticked, leave the password blank.',
234
+ 'Press Apply, then Save and reconnect here.',
235
+ ],
236
+ },
237
+ { type: 'note', text: 'Running OBS on another machine? Put its address in Host, and make sure that machine allows the connection through its firewall.' },
238
+ {
239
+ type: 'text',
240
+ text: 'Once connected, your graphics can react to the scene that is live — a lower third that hides itself when the camera cuts away, or a badge that only shows while you are actually streaming.',
241
+ },
242
+ ],
243
+ config: [
244
+ { key: 'host', label: 'Host', default: 'localhost', help: 'The machine running OBS. Usually this one.' },
245
+ { key: 'port', label: 'Port', type: 'number', default: 4455, help: 'Tools → WebSocket Server Settings.' },
246
+ { key: 'password', label: 'Password', type: 'secret', help: 'Only if OBS is asking for one.' },
247
+ { key: 'events', label: 'Events', help: 'Comma separated. Blank for all of them.' },
248
+ ],
249
+ create: (context) => {
250
+ const plugin = new OBS(context)
251
+
252
+ new Handler({ ...context, plugin }).attach(plugin.events)
253
+
254
+ return plugin
255
+ },
256
+ })
@@ -0,0 +1,146 @@
1
+ // obs-websocket v5, as data and pure functions with no socket in them.
2
+ //
3
+ // Verified against obsproject/obs-websocket docs/generated/protocol.md rather than
4
+ // written from memory. The parts that are easy to get subtly wrong -- the auth
5
+ // hash, the subscription bitmask, request correlation -- are all here, where a test
6
+ // can check them against the worked example in that document.
7
+
8
+ /** Opcodes, both directions. */
9
+ export const OP = {
10
+ HELLO: 0,
11
+ IDENTIFY: 1,
12
+ IDENTIFIED: 2,
13
+ REIDENTIFY: 3,
14
+ EVENT: 5,
15
+ REQUEST: 6,
16
+ RESPONSE: 7,
17
+ BATCH: 8,
18
+ BATCH_RESPONSE: 9,
19
+ }
20
+
21
+ /**
22
+ * Event categories, as a bitmask.
23
+ *
24
+ * Deliberately not `All`. Its value has changed -- `Canvases` joined it in
25
+ * obs-websocket 5.7.0 -- so "all" means different numbers to different versions of
26
+ * OBS, and asking for categories a studio does not read is paying for events that
27
+ * cross the socket to be discarded. A studio subscribes to what it uses.
28
+ *
29
+ * The high-volume ones are above 1 << 15 and are never included by default, by
30
+ * OBS's own rule: `InputVolumeMeters` alone is dozens of messages a second.
31
+ */
32
+ export const CATEGORY = {
33
+ none: 0,
34
+ general: 1 << 0,
35
+ config: 1 << 1,
36
+ scenes: 1 << 2,
37
+ inputs: 1 << 3,
38
+ transitions: 1 << 4,
39
+ filters: 1 << 5,
40
+ outputs: 1 << 6,
41
+ sceneItems: 1 << 7,
42
+ mediaInputs: 1 << 8,
43
+ vendors: 1 << 9,
44
+ ui: 1 << 10,
45
+ canvases: 1 << 11,
46
+ }
47
+
48
+ /**
49
+ * @param {string[]} names
50
+ * @returns {number}
51
+ */
52
+ export const maskOf = (names = []) => names.reduce((mask, name) => mask | (CATEGORY[name] ?? 0), 0)
53
+
54
+ const b64 = (bytes) => {
55
+ let binary = ''
56
+
57
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
58
+
59
+ return btoa(binary)
60
+ }
61
+
62
+ const sha256 = async (text) => b64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
63
+
64
+ /**
65
+ * The answer to OBS's challenge.
66
+ *
67
+ * Two rounds, and the order matters in both: the password is hashed with the salt
68
+ * to make a secret, and that secret -- base64, not raw bytes -- is hashed with the
69
+ * challenge. Concatenating in the other order, or hashing the raw digest rather
70
+ * than its base64, produces a string that is the right length and always wrong.
71
+ *
72
+ * @param {string} password
73
+ * @param {string} salt
74
+ * @param {string} challenge
75
+ * @returns {Promise<string>}
76
+ */
77
+ export async function authenticate(password, salt, challenge) {
78
+ const secret = await sha256(password + salt)
79
+
80
+ return sha256(secret + challenge)
81
+ }
82
+
83
+ /**
84
+ * What to do about a message from OBS.
85
+ *
86
+ * @param {unknown} raw Already JSON.parse'd.
87
+ */
88
+ export function classify(raw) {
89
+ const op = raw?.op
90
+ const d = raw?.d
91
+
92
+ switch (op) {
93
+ case OP.HELLO:
94
+ return {
95
+ do: 'identify',
96
+ rpcVersion: d?.rpcVersion ?? 1,
97
+ // Absent when OBS has authentication switched off, which is a normal
98
+ // configuration and not an error.
99
+ auth: d?.authentication ?? null,
100
+ obs: d?.obsStudioVersion ?? null,
101
+ websocket: d?.obsWebSocketVersion ?? null,
102
+ }
103
+
104
+ case OP.IDENTIFIED:
105
+ return { do: 'ready', rpcVersion: d?.negotiatedRpcVersion ?? null }
106
+
107
+ case OP.EVENT:
108
+ return { do: 'event', type: d?.eventType ?? null, data: d?.eventData ?? {} }
109
+
110
+ case OP.RESPONSE:
111
+ return {
112
+ do: 'response',
113
+ id: d?.requestId ?? null,
114
+ ok: Boolean(d?.requestStatus?.result),
115
+ // `comment` is the only thing that says *why*, and it is absent on success.
116
+ reason: d?.requestStatus?.comment ?? d?.requestStatus?.code ?? null,
117
+ data: d?.responseData ?? {},
118
+ }
119
+
120
+ default:
121
+ return { do: 'ignore', reason: `unhandled opcode ${op}` }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The Identify frame.
127
+ *
128
+ * @param {object} options
129
+ * @param {number} options.rpcVersion
130
+ * @param {string} [options.authentication]
131
+ * @param {number} options.eventSubscriptions
132
+ */
133
+ export const identify = ({ rpcVersion, authentication, eventSubscriptions }) => ({
134
+ op: OP.IDENTIFY,
135
+ d: {
136
+ rpcVersion,
137
+ ...(authentication ? { authentication } : {}),
138
+ eventSubscriptions,
139
+ },
140
+ })
141
+
142
+ /** A request frame. `requestId` is what correlates the reply. */
143
+ export const request = (requestType, requestId, requestData) => ({
144
+ op: OP.REQUEST,
145
+ d: { requestType, requestId, ...(requestData ? { requestData } : {}) },
146
+ })