streampal-connect 1.0.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/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # streampal-connect
2
+
3
+ The one-command agent that links your OBS to your [StreamPal](https://streampal.gg)
4
+ dashboard and phone stream deck.
5
+
6
+ ```bash
7
+ npx streampal-connect <your-pair-code>
8
+ ```
9
+
10
+ Find your pair code on your dashboard at **streampal.gg → OBS**.
11
+
12
+ It:
13
+
14
+ - **auto-detects OBS** on this PC (`ws://127.0.0.1:4455`) — asks for the OBS
15
+ WebSocket password once, then remembers it,
16
+ - connects **outbound** to StreamPal's relay, so your home network needs no
17
+ port-forwarding or firewall changes,
18
+ - bridges the two: buttons on your dashboard / phone run against OBS here.
19
+
20
+ Leave the window open while you stream. `Ctrl+C` to stop. Next time it's just
21
+ `npx streampal-connect` — your code and password are saved to
22
+ `~/.streampal/connect.json` (that file never leaves your machine).
23
+
24
+ ## Options
25
+
26
+ | Flag | Meaning |
27
+ | --- | --- |
28
+ | `--relay <wss://…>` | Use a different relay (default: StreamPal's public relay) |
29
+ | `--obs-url <ws://…>` | OBS WebSocket URL (default `ws://127.0.0.1:4455`) |
30
+ | `--obs-password <pw>` | Pass the OBS password non-interactively |
31
+ | `--reset` | Forget saved settings and set up fresh |
32
+ | `--help` | Show help |
33
+
34
+ Environment variables `STREAMPAL_PAIR_CODE`, `STREAMPAL_RELAY_URL`,
35
+ `OBS_PASSWORD` are also honoured.
36
+
37
+ ## First-time OBS setup
38
+
39
+ In OBS: **Tools → WebSocket Server Settings → Enable WebSocket server**. Note
40
+ the password (Show Connect Info). That's the only thing you paste besides your
41
+ pair code.
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ // streampal-connect
3
+ // -----------------
4
+ // The one-command agent. Run it on the PC where OBS lives:
5
+ //
6
+ // npx streampal-connect <your-pair-code>
7
+ //
8
+ // It auto-detects OBS on localhost, connects OUTBOUND to StreamPal's relay
9
+ // (so your home network needs no port-forwarding), and bridges the two. Press
10
+ // a button on your dashboard / phone stream deck and it runs against OBS here.
11
+ //
12
+ // Nothing is stored on StreamPal's servers except your account's pair code.
13
+ // Your OBS password stays on this machine (~/.streampal/connect.json).
14
+
15
+ "use strict";
16
+
17
+ const fs = require("fs");
18
+ const os = require("os");
19
+ const path = require("path");
20
+ const readline = require("readline");
21
+ const WebSocket = require("ws");
22
+ const { OBSWebSocket } = require("obs-websocket-js");
23
+
24
+ // -------------------------------------------------------------- tiny ui
25
+
26
+ const C = {
27
+ reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m",
28
+ green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m",
29
+ cyan: "\x1b[36m", magenta: "\x1b[35m",
30
+ };
31
+ const ok = (m) => console.log(`${C.green}✓${C.reset} ${m}`);
32
+ const warn = (m) => console.log(`${C.yellow}!${C.reset} ${m}`);
33
+ const err = (m) => console.log(`${C.red}✗${C.reset} ${m}`);
34
+ const info = (m) => console.log(`${C.dim}${m}${C.reset}`);
35
+
36
+ function banner() {
37
+ console.log(`${C.magenta}${C.bold}
38
+ ┌────────────────────────────────────┐
39
+ │ StreamPal Connect │
40
+ └────────────────────────────────────┘${C.reset}`);
41
+ }
42
+
43
+ // -------------------------------------------------------------- args + config
44
+
45
+ const argv = process.argv.slice(2);
46
+ function flag(name) {
47
+ const i = argv.indexOf(`--${name}`);
48
+ return i >= 0 ? (argv[i + 1] || "") : undefined;
49
+ }
50
+ const hasFlag = (name) => argv.includes(`--${name}`);
51
+
52
+ const positional = argv.filter((a) => !a.startsWith("--"));
53
+ // crude: skip values that belong to a flag
54
+ const flagValues = new Set();
55
+ for (let i = 0; i < argv.length; i++) {
56
+ if (argv[i].startsWith("--") && argv[i + 1] && !argv[i + 1].startsWith("--")) {
57
+ flagValues.add(argv[i + 1]);
58
+ }
59
+ }
60
+ const positionalPairCode = positional.find((p) => !flagValues.has(p));
61
+
62
+ const CONFIG_DIR = path.join(os.homedir(), ".streampal");
63
+ const CONFIG_PATH = path.join(CONFIG_DIR, "connect.json");
64
+
65
+ function loadConfig() {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+ function saveConfig(cfg) {
73
+ try {
74
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
75
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
76
+ } catch (e) {
77
+ warn(`couldn't save settings: ${e.message}`);
78
+ }
79
+ }
80
+
81
+ if (hasFlag("reset")) {
82
+ try { fs.unlinkSync(CONFIG_PATH); } catch {}
83
+ ok("cleared saved settings");
84
+ }
85
+ if (hasFlag("help") || hasFlag("h")) {
86
+ banner();
87
+ console.log(`
88
+ Usage: npx streampal-connect <pair-code> [options]
89
+
90
+ Options:
91
+ --relay <wss://...> Relay URL (default StreamPal's public relay)
92
+ --obs-url <ws://...> OBS WebSocket URL (default ws://127.0.0.1:4455)
93
+ --obs-password <pw> OBS WebSocket password (else you'll be asked once)
94
+ --reset Forget saved settings and set up fresh
95
+ --help Show this help
96
+
97
+ Your pair code is on your dashboard at streampal.gg → OBS.
98
+ `);
99
+ process.exit(0);
100
+ }
101
+
102
+ const cfg = loadConfig();
103
+
104
+ const DEFAULT_RELAY = process.env.STREAMPAL_RELAY_URL || "wss://relay.streampal.gg";
105
+ const RELAY_URL = (flag("relay") || cfg.relayUrl || DEFAULT_RELAY).replace(/\/$/, "");
106
+ const OBS_URL = flag("obs-url") || cfg.obsUrl || "ws://127.0.0.1:4455";
107
+ let PAIR_CODE = positionalPairCode || process.env.STREAMPAL_PAIR_CODE || cfg.pairCode || "";
108
+ let OBS_PASSWORD =
109
+ flag("obs-password") !== undefined
110
+ ? flag("obs-password")
111
+ : process.env.OBS_PASSWORD !== undefined
112
+ ? process.env.OBS_PASSWORD
113
+ : cfg.obsPassword;
114
+
115
+ // -------------------------------------------------------------- prompts
116
+
117
+ function ask(question, { hidden = false } = {}) {
118
+ return new Promise((resolve) => {
119
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
120
+ if (hidden) {
121
+ // Mask keystrokes for the OBS password.
122
+ const onData = (char) => {
123
+ char = char + "";
124
+ if (char === "\n" || char === "\r" || char === "") {
125
+ process.stdin.removeListener("data", onData);
126
+ } else {
127
+ readline.clearLine(process.stdout, 0);
128
+ readline.cursorTo(process.stdout, 0);
129
+ process.stdout.write(question + "*".repeat(rl.line.length));
130
+ }
131
+ };
132
+ process.stdin.on("data", onData);
133
+ }
134
+ rl.question(question, (answer) => {
135
+ rl.close();
136
+ if (hidden) process.stdout.write("\n");
137
+ resolve(answer.trim());
138
+ });
139
+ });
140
+ }
141
+
142
+ // -------------------------------------------------------------- OBS
143
+
144
+ const obs = new OBSWebSocket();
145
+ let obsConnected = false;
146
+ let relay = null;
147
+ let relayTimer = null;
148
+
149
+ async function tryObs(url, password) {
150
+ const client = new OBSWebSocket();
151
+ try {
152
+ await client.connect(url, password || undefined);
153
+ await client.disconnect();
154
+ return { ok: true };
155
+ } catch (e) {
156
+ const msg = (e && e.message) || String(e);
157
+ // obs-websocket-js: code 4009 == auth failed, 4008 == auth required
158
+ const authIssue = /authentication|4009|4008|password/i.test(msg);
159
+ return { ok: false, authIssue, msg };
160
+ }
161
+ }
162
+
163
+ async function ensureObsCredentials() {
164
+ info(`Looking for OBS at ${OBS_URL} …`);
165
+ let probe = await tryObs(OBS_URL, OBS_PASSWORD);
166
+ if (probe.ok) {
167
+ ok("found OBS");
168
+ return;
169
+ }
170
+ if (!probe.authIssue) {
171
+ warn("OBS isn't reachable yet — make sure OBS is open and its WebSocket");
172
+ warn("server is enabled (Tools → WebSocket Server Settings). I'll keep trying.");
173
+ return; // connectOBS() will retry in the loop
174
+ }
175
+ // Auth problem — ask for the password (once) and re-probe.
176
+ for (let attempt = 0; attempt < 3; attempt++) {
177
+ warn("OBS needs its WebSocket password.");
178
+ info("Find it in OBS → Tools → WebSocket Server Settings → Show Connect Info.");
179
+ const pw = await ask(`${C.cyan}OBS password: ${C.reset}`, { hidden: true });
180
+ OBS_PASSWORD = pw;
181
+ probe = await tryObs(OBS_URL, OBS_PASSWORD);
182
+ if (probe.ok) {
183
+ ok("password accepted");
184
+ return;
185
+ }
186
+ err("that password didn't work — try again");
187
+ }
188
+ err("couldn't authenticate with OBS. Run again with the right password.");
189
+ process.exit(1);
190
+ }
191
+
192
+ async function connectOBS() {
193
+ try {
194
+ await obs.connect(OBS_URL, OBS_PASSWORD || undefined);
195
+ obsConnected = true;
196
+ ok(`OBS connected (${OBS_URL})`);
197
+ pushStatus();
198
+ } catch (e) {
199
+ obsConnected = false;
200
+ info(`waiting for OBS … (${e.message})`);
201
+ setTimeout(connectOBS, 5000);
202
+ }
203
+ }
204
+
205
+ obs.on("ConnectionClosed", () => {
206
+ if (!obsConnected) return;
207
+ obsConnected = false;
208
+ warn("OBS closed — reconnecting");
209
+ pushStatus();
210
+ setTimeout(connectOBS, 5000);
211
+ });
212
+
213
+ // keep dashboard buttons in sync with changes made directly in OBS
214
+ obs.on("StreamStateChanged", (d) => toRelay({ type: "event", event: "stream", active: d.outputActive }));
215
+ obs.on("RecordStateChanged", (d) => toRelay({ type: "event", event: "record", active: d.outputActive }));
216
+ obs.on("CurrentProgramSceneChanged", (d) => toRelay({ type: "event", event: "scene", scene: d.sceneName }));
217
+ obs.on("InputMuteStateChanged", (d) => toRelay({ type: "event", event: "mute", inputName: d.inputName, muted: d.inputMuted }));
218
+ obs.on("VirtualcamStateChanged", (d) => toRelay({ type: "event", event: "virtualcam", active: d.outputActive }));
219
+ obs.on("ReplayBufferStateChanged", (d) => toRelay({ type: "event", event: "replay", active: d.outputActive }));
220
+
221
+ // -------------------------------------------------------------- relay
222
+
223
+ let relayReady = false;
224
+
225
+ function connectRelay() {
226
+ const url = `${RELAY_URL}/?role=agent&code=${encodeURIComponent(PAIR_CODE)}`;
227
+ info(`Connecting to relay …`);
228
+ relay = new WebSocket(url);
229
+
230
+ relay.on("open", () => {
231
+ relayReady = true;
232
+ ok("relay connected — your dashboard can now control OBS");
233
+ console.log(`${C.dim} Leave this window open while you stream. Ctrl+C to stop.${C.reset}`);
234
+ pushStatus();
235
+ });
236
+ relay.on("message", (raw) => {
237
+ let msg;
238
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
239
+ if (msg.type === "command") handleCommand(msg);
240
+ });
241
+ relay.on("close", (code) => {
242
+ if (relayReady) warn("relay disconnected — reconnecting");
243
+ else if (code === 1008 || code === 4401) {
244
+ err("relay rejected the pair code. Copy it again from streampal.gg → OBS.");
245
+ process.exit(1);
246
+ }
247
+ relayReady = false;
248
+ scheduleRelayReconnect();
249
+ });
250
+ relay.on("error", (e) => {
251
+ if (!relayReady) err(`relay error: ${e.message}`);
252
+ });
253
+ }
254
+ function scheduleRelayReconnect() {
255
+ if (relayTimer) return;
256
+ relayTimer = setTimeout(() => { relayTimer = null; connectRelay(); }, 3000);
257
+ }
258
+ function toRelay(obj) {
259
+ if (relay && relay.readyState === WebSocket.OPEN) relay.send(JSON.stringify(obj));
260
+ }
261
+ function pushStatus() {
262
+ toRelay({ type: "obs-status", connected: obsConnected });
263
+ }
264
+
265
+ // -------------------------------------------------------------- commands
266
+
267
+ async function handleCommand(msg) {
268
+ const { id, action, params } = msg;
269
+ try {
270
+ let data;
271
+ if (action === "snapshot") {
272
+ const sceneList = await obs.call("GetSceneList");
273
+ let streaming = false, recording = false, virtualCam = false, replayBuffer = false;
274
+ try { streaming = (await obs.call("GetStreamStatus")).outputActive; } catch {}
275
+ try { recording = (await obs.call("GetRecordStatus")).outputActive; } catch {}
276
+ try { virtualCam = (await obs.call("GetVirtualCamStatus")).outputActive; } catch {}
277
+ try { replayBuffer = (await obs.call("GetReplayBufferStatus")).outputActive; } catch {}
278
+ const audioInputs = [];
279
+ try {
280
+ const { inputs } = await obs.call("GetInputList");
281
+ for (const input of inputs) {
282
+ try {
283
+ const { inputMuted } = await obs.call("GetInputMute", { inputName: input.inputName });
284
+ audioInputs.push({ name: input.inputName, muted: inputMuted });
285
+ } catch { /* not an audio input */ }
286
+ }
287
+ } catch {}
288
+ data = {
289
+ scenes: sceneList.scenes.map((s) => s.sceneName).reverse(),
290
+ currentScene: sceneList.currentProgramSceneName,
291
+ streaming, recording, virtualCam, replayBuffer, audioInputs,
292
+ };
293
+ } else {
294
+ data = await obs.call(action, params || {});
295
+ }
296
+ toRelay({ type: "result", id, ok: true, data });
297
+ } catch (e) {
298
+ err(`"${action}" failed: ${e.message}`);
299
+ toRelay({ type: "result", id, ok: false, error: e.message });
300
+ }
301
+ }
302
+
303
+ // -------------------------------------------------------------- boot
304
+
305
+ async function main() {
306
+ banner();
307
+
308
+ if (!PAIR_CODE) {
309
+ info("Find your pair code on your dashboard: streampal.gg → OBS.");
310
+ PAIR_CODE = await ask(`${C.cyan}Pair code: ${C.reset}`);
311
+ if (!PAIR_CODE) {
312
+ err("a pair code is required.");
313
+ process.exit(1);
314
+ }
315
+ }
316
+
317
+ await ensureObsCredentials();
318
+
319
+ // Persist what we learned so next time it's truly one command.
320
+ saveConfig({
321
+ pairCode: PAIR_CODE,
322
+ obsUrl: OBS_URL,
323
+ obsPassword: OBS_PASSWORD || "",
324
+ relayUrl: RELAY_URL === DEFAULT_RELAY ? undefined : RELAY_URL,
325
+ });
326
+
327
+ console.log();
328
+ info(`relay : ${RELAY_URL}`);
329
+ info(`obs : ${OBS_URL}`);
330
+ info(`code : ${PAIR_CODE.slice(0, 6)}…`);
331
+ console.log();
332
+
333
+ connectOBS();
334
+ connectRelay();
335
+ }
336
+
337
+ process.on("SIGINT", () => {
338
+ console.log(`\n${C.dim}Stopping StreamPal Connect. Bye!${C.reset}`);
339
+ process.exit(0);
340
+ });
341
+
342
+ main().catch((e) => {
343
+ err(e.message);
344
+ process.exit(1);
345
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "streampal-connect",
3
+ "version": "1.0.0",
4
+ "description": "Link your OBS to your StreamPal dashboard. Runs on your streaming PC and connects outbound to StreamPal's relay — no port-forwarding, no config.",
5
+ "keywords": ["streampal", "obs", "obs-websocket", "streaming", "stream-deck"],
6
+ "homepage": "https://streampal.gg/download",
7
+ "license": "MIT",
8
+ "type": "commonjs",
9
+ "bin": {
10
+ "streampal-connect": "bin/streampal-connect.js"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "dependencies": {
20
+ "obs-websocket-js": "^5.0.6",
21
+ "ws": "^8.18.0"
22
+ }
23
+ }