Skip to content

Commit c35d5be

Browse files
committed
Add Studio provider bridge
1 parent 2612d03 commit c35d5be

4 files changed

Lines changed: 129 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ Use `simdeck ui --open` or `simdeck daemon start` when you want a reusable backg
6363
The no-subcommand lifecycle shortcuts are `simdeck -d` for detached start, `simdeck -k` to kill the background daemon, and `simdeck -r` to restart it.
6464
The served loopback browser UI receives the generated API access token automatically. LAN browsers pair with the printed code before receiving the API cookie.
6565

66+
SimDeck Studio providers run the daemon on loopback and use
67+
`scripts/studio-provider-bridge.mjs` for outbound control-plane communication
68+
with Studio. Studio hosts the browser UI and proxies SimDeck REST requests over
69+
that bridge while WebRTC media still negotiates directly between the browser and
70+
runner through ICE.
71+
6672
CLI commands automatically use the same warm daemon:
6773

6874
```sh

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"README.md",
1212
"bin/",
1313
"scripts/experimental/",
14+
"scripts/studio-provider-bridge.mjs",
1415
"scripts/postinstall.mjs",
1516
"build/simdeck-bin",
1617
"client/dist/",

scripts/studio-provider-bridge.mjs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env node
2+
3+
const cloudUrl = requiredEnv("SIMDECK_CLOUD_URL").replace(/\/$/, "");
4+
const previewId = requiredEnv("PREVIEW_ID");
5+
const providerToken = requiredEnv("PROVIDER_TOKEN");
6+
const localUrl = (
7+
process.env.SIMDECK_LOCAL_URL || "http://127.0.0.1:4310"
8+
).replace(/\/$/, "");
9+
10+
let stopped = false;
11+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
12+
process.once(signal, () => {
13+
stopped = true;
14+
});
15+
}
16+
17+
while (!stopped) {
18+
try {
19+
const next = await fetchJson(`${cloudUrl}/api/actions/providers/rpc/next`, {
20+
previewId,
21+
providerToken,
22+
});
23+
if (!next || !next.request) {
24+
await sleep(250);
25+
continue;
26+
}
27+
await handleRequest(next.request);
28+
} catch (error) {
29+
console.error(
30+
`[simdeck-provider-bridge] ${error instanceof Error ? error.message : String(error)}`,
31+
);
32+
await sleep(1000);
33+
}
34+
}
35+
36+
async function handleRequest(request) {
37+
try {
38+
const target = new URL(request.path, `${localUrl}/`);
39+
if (!target.searchParams.has("simdeckToken")) {
40+
target.searchParams.set("simdeckToken", providerToken);
41+
}
42+
const headers = new Headers(request.headers || {});
43+
headers.set("x-simdeck-token", providerToken);
44+
headers.delete("host");
45+
headers.delete("content-length");
46+
const response = await fetch(target, {
47+
body: request.bodyBase64
48+
? Buffer.from(request.bodyBase64, "base64")
49+
: undefined,
50+
headers,
51+
method: request.method,
52+
});
53+
const responseHeaders = {};
54+
for (const [name, value] of response.headers.entries()) {
55+
const lower = name.toLowerCase();
56+
if (
57+
lower === "connection" ||
58+
lower === "content-encoding" ||
59+
lower === "content-length" ||
60+
lower === "transfer-encoding"
61+
) {
62+
continue;
63+
}
64+
responseHeaders[name] = value;
65+
}
66+
const responseBodyBase64 = Buffer.from(
67+
await response.arrayBuffer(),
68+
).toString("base64");
69+
await complete({
70+
requestId: request.id,
71+
responseStatus: response.status,
72+
responseHeaders,
73+
responseBodyBase64,
74+
});
75+
} catch (error) {
76+
await complete({
77+
requestId: request.id,
78+
error: error instanceof Error ? error.message : String(error),
79+
});
80+
}
81+
}
82+
83+
async function complete(payload) {
84+
await fetchJson(`${cloudUrl}/api/actions/providers/rpc/complete`, {
85+
previewId,
86+
providerToken,
87+
...payload,
88+
});
89+
}
90+
91+
async function fetchJson(url, body) {
92+
const response = await fetch(url, {
93+
body: JSON.stringify(body),
94+
headers: { "content-type": "application/json" },
95+
method: "POST",
96+
});
97+
if (response.status === 204) {
98+
return null;
99+
}
100+
if (!response.ok) {
101+
throw new Error(
102+
`${url} failed with ${response.status}: ${await response.text()}`,
103+
);
104+
}
105+
return response.json();
106+
}
107+
108+
function requiredEnv(name) {
109+
const value = process.env[name];
110+
if (!value) {
111+
throw new Error(`${name} is required.`);
112+
}
113+
return value;
114+
}
115+
116+
function sleep(ms) {
117+
return new Promise((resolve) => setTimeout(resolve, ms));
118+
}

skills/simdeck/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ TURN server and relay-only ICE:
3939
`SIMDECK_WEBRTC_ICE_SERVERS=turns:turn.example.com:5349?transport=tcp`,
4040
`SIMDECK_WEBRTC_ICE_USERNAME`, `SIMDECK_WEBRTC_ICE_CREDENTIAL`, and
4141
`SIMDECK_WEBRTC_ICE_TRANSPORT_POLICY=relay`.
42+
SimDeck Studio provider runners keep SimDeck bound to loopback and run
43+
`scripts/studio-provider-bridge.mjs` as an outbound bridge; Studio hosts the UI
44+
and proxies REST requests through that bridge while WebRTC media negotiates
45+
directly with the runner.
4246

4347
The local viewer gets the API token automatically. LAN browsers pair with the printed code before receiving the API cookie. Direct HTTP calls need `X-SimDeck-Token` or `Authorization: Bearer <token>`.
4448

0 commit comments

Comments
 (0)