@rainbow-robotics/muscat-plugin-kit 1.3.8-dev.0 → 1.3.8-dev.2
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 +5 -5
- package/cli/README.md +4 -4
- package/cli/bin/muscat-pk.mjs +1 -1
- package/cli/bundle-templates.mjs +0 -2
- package/cli/docker/Dockerfile.cpp +6 -23
- package/cli/docker/Dockerfile.dev +6 -22
- package/cli/docker/Dockerfile.python +0 -18
- package/cli/src/cpp.mjs +2 -2
- package/cli/src/dev.mjs +19 -33
- package/cli/src/devBack.mjs +9 -21
- package/cli/src/muscatLocal.mjs +51 -13
- package/cli/src/ports.mjs +1 -1
- package/cli/src/python.mjs +2 -2
- package/cli/src/ros2.mjs +2 -2
- package/cli/src/util.mjs +6 -2
- package/cli/storybook-dist/project.json +1 -1
- package/cli/templates/common/README.md +10 -22
- package/cli/templates/cpp/cpp/CMakeLists.txt +1 -1
- package/cli/templates/readme-cpp/README.md +11 -22
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +1 -1
- package/package.json +3 -4
- package/cli/docker/Dockerfile.bridge +0 -17
- package/cli/docker/bridge-package.json +0 -8
- package/cli/src/bridge.mjs +0 -152
- package/cli/src/socketBridge.mjs +0 -133
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ Move images to a machine with no internet:
|
|
|
159
159
|
|
|
160
160
|
```bash
|
|
161
161
|
# on a machine with network
|
|
162
|
-
docker save muscat-pk-python-dev muscat-pk-
|
|
162
|
+
docker save muscat-pk-python-dev muscat-pk-runtime | gzip > muscat-pk-images.tar.gz
|
|
163
163
|
|
|
164
164
|
# on the offline machine
|
|
165
165
|
gunzip -c muscat-pk-images.tar.gz | docker load
|
|
@@ -198,12 +198,12 @@ The runtime monitor is strict on purpose: an undeclared port outside the plugin
|
|
|
198
198
|
range is invisible to both the monitor and the robot's conflict check, so it
|
|
199
199
|
would only surface as a collision after deployment.
|
|
200
200
|
|
|
201
|
-
`muscat-pk`'s own dev plumbing (the
|
|
201
|
+
`muscat-pk`'s own dev plumbing (the plugin gateway on `10000`, the Zenoh dev
|
|
202
202
|
peer port) is excluded from the check — it isn't the plugin's traffic.
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
Under `muscat-pk dev` declared ports are published to `127.0.0.1` from the local
|
|
205
|
+
Muscat container (the backend shares its network), so you can reach the backend
|
|
206
|
+
from the host.
|
|
207
207
|
|
|
208
208
|
## Variables (pyfm)
|
|
209
209
|
|
package/cli/README.md
CHANGED
|
@@ -42,12 +42,12 @@ the backend**, whatever its number. Since a port outside `28600-28999` cannot be
|
|
|
42
42
|
declared, it cannot be opened either — otherwise it would slip past both this
|
|
43
43
|
check and the robot's install-time conflict check.
|
|
44
44
|
|
|
45
|
-
The kit's own dev ports (
|
|
45
|
+
The kit's own dev ports (plugin gateway `10000`, the Zenoh dev peer port) are
|
|
46
46
|
excluded from that check.
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
Under `muscat-pk dev` the backend shares the local Muscat container's network, so
|
|
49
|
+
declared ports are published to `127.0.0.1` from that container. A declared port
|
|
50
|
+
that is already busy on the host is skipped with a warning.
|
|
51
51
|
|
|
52
52
|
Known robot/industrial/internal ports such as `502`, `4840`, `5170-5179`,
|
|
53
53
|
`8000-8999`, `10000`, `2222`, `30004`, `34964`, `44818`, `50000-50005`,
|
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -81,7 +81,7 @@ function formatHelp() {
|
|
|
81
81
|
commandRow("new [dir]", `Scaffold a new plugin project ${color.dim("(prompts: author, email, description, type, front-package-manager)")}`),
|
|
82
82
|
commandRow("type change", `Switch backend type ${color.dim("(python | cpp | ros2-python | ros2-cpp)")}`),
|
|
83
83
|
commandRow("build", `Build + lock deps + vendor wheels + zip ${color.dim(`(${option("--no-zip")}: skip zip, ${option("--no-lock")}: skip python lock/vendor, ${option("--arch")} amd64|arm64|both)`)}`),
|
|
84
|
-
commandRow("dev", "Run
|
|
84
|
+
commandRow("dev", "Run a local Muscat runtime, dev:ui, and dev:back together"),
|
|
85
85
|
commandRow("dev:ui", `Preview the plugin in a mini-Muscat ${color.dim("(preview-host)")}`),
|
|
86
86
|
commandRow("dev:back", `Run the plugin back end ${color.dim("(python: watchfiles reload / ros2: Docker)")}`),
|
|
87
87
|
commandRow("storybook", `Browse the bundled rb-components Storybook ${color.dim(`(${option("--port")} <n>, ${option("--rebuild")})`)}`),
|
package/cli/bundle-templates.mjs
CHANGED
|
@@ -19,8 +19,6 @@ function walk(baseDir, prefix) {
|
|
|
19
19
|
}
|
|
20
20
|
walk(resolve(root, "templates") + "/", "");
|
|
21
21
|
walk(resolve(root, "docker") + "/", "docker/");
|
|
22
|
-
// Dockerfile.bridge 의 COPY 대상
|
|
23
|
-
files["src/socketBridge.mjs"] = readFileSync(resolve(root, "src/socketBridge.mjs"), "base64");
|
|
24
22
|
|
|
25
23
|
const { version } = JSON.parse(readFileSync(resolve(root, "../package.json"), "utf8"));
|
|
26
24
|
|
|
@@ -10,35 +10,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
10
10
|
cmake \
|
|
11
11
|
&& rm -rf /var/lib/apt/lists/*
|
|
12
12
|
|
|
13
|
-
# Rainbow Robotics C++ SDK for find_package(rb_sdk).
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
ARG RB_SDK_VERSION=
|
|
13
|
+
# Rainbow Robotics C++ SDK for find_package(rb_sdk). muscat-pk passes the version pinned in the
|
|
14
|
+
# project's CMakeLists.txt (find_package(rb_sdk <ver> EXACT)); without a pin (ROS 2 rclpy, `muscat-pk mcp`
|
|
15
|
+
# outside a project) the newest SDK is installed. Runtime and -dev are installed together at one version.
|
|
16
|
+
ARG RB_SDK_VERSION=
|
|
17
17
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
|
|
18
18
|
&& install -d /etc/apt/keyrings \
|
|
19
19
|
&& curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
|
|
20
20
|
&& printf 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk %s main\n' stable dev > /etc/apt/sources.list.d/rb-sdk.list \
|
|
21
|
-
&& apt-get update
|
|
21
|
+
&& apt-get update \
|
|
22
|
+
&& apt-get install -y --no-install-recommends "rainbow-rb-sdk${RB_SDK_VERSION:+=$RB_SDK_VERSION}" "rainbow-rb-sdk-dev${RB_SDK_VERSION:+=$RB_SDK_VERSION}" \
|
|
22
23
|
&& rm -rf /var/lib/apt/lists/*
|
|
23
24
|
|
|
24
|
-
# MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
|
|
25
|
-
# plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
|
|
26
|
-
ARG MEDIAMTX_VERSION=1.12.3
|
|
27
|
-
RUN set -eux; \
|
|
28
|
-
apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
|
|
29
|
-
case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
|
|
30
|
-
curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
|
|
31
|
-
| tar -xz -C /usr/local/bin mediamtx; \
|
|
32
|
-
mkdir -p /etc/mediamtx; \
|
|
33
|
-
printf '%s\n' \
|
|
34
|
-
'logLevel: warn' \
|
|
35
|
-
'rtsp: yes' 'rtspAddress: :8554' \
|
|
36
|
-
'webrtc: yes' 'webrtcAddress: :8889' \
|
|
37
|
-
'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
|
|
38
|
-
'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
|
|
39
|
-
'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
|
|
40
|
-
'paths:' ' all_others:' ' source: publisher' \
|
|
41
|
-
> /etc/mediamtx/mediamtx.yml
|
|
42
25
|
|
|
43
26
|
WORKDIR /plugin
|
|
44
27
|
|
|
@@ -9,34 +9,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
9
9
|
python3-colcon-common-extensions \
|
|
10
10
|
&& rm -rf /var/lib/apt/lists/*
|
|
11
11
|
|
|
12
|
-
# Rainbow Robotics C++ SDK for find_package(rb_sdk).
|
|
13
|
-
#
|
|
14
|
-
|
|
12
|
+
# Rainbow Robotics C++ SDK for find_package(rb_sdk). muscat-pk passes the version pinned in the
|
|
13
|
+
# project's CMakeLists.txt (find_package(rb_sdk <ver> EXACT)); without a pin (ROS 2 rclpy, `muscat-pk mcp`
|
|
14
|
+
# outside a project) the newest SDK is installed. Runtime and -dev are installed together at one version.
|
|
15
|
+
ARG RB_SDK_VERSION=
|
|
15
16
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
|
|
16
17
|
&& install -d /etc/apt/keyrings \
|
|
17
18
|
&& curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
|
|
18
19
|
&& printf 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk %s main\n' stable dev > /etc/apt/sources.list.d/rb-sdk.list \
|
|
19
|
-
&& apt-get update
|
|
20
|
+
&& apt-get update \
|
|
21
|
+
&& apt-get install -y --no-install-recommends "rainbow-rb-sdk${RB_SDK_VERSION:+=$RB_SDK_VERSION}" "rainbow-rb-sdk-dev${RB_SDK_VERSION:+=$RB_SDK_VERSION}" \
|
|
20
22
|
&& rm -rf /var/lib/apt/lists/*
|
|
21
23
|
|
|
22
|
-
# MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
|
|
23
|
-
# plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
|
|
24
|
-
ARG MEDIAMTX_VERSION=1.12.3
|
|
25
|
-
RUN set -eux; \
|
|
26
|
-
apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
|
|
27
|
-
case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
|
|
28
|
-
curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
|
|
29
|
-
| tar -xz -C /usr/local/bin mediamtx; \
|
|
30
|
-
mkdir -p /etc/mediamtx; \
|
|
31
|
-
printf '%s\n' \
|
|
32
|
-
'logLevel: warn' \
|
|
33
|
-
'rtsp: yes' 'rtspAddress: :8554' \
|
|
34
|
-
'webrtc: yes' 'webrtcAddress: :8889' \
|
|
35
|
-
'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
|
|
36
|
-
'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
|
|
37
|
-
'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
|
|
38
|
-
'paths:' ' all_others:' ' source: publisher' \
|
|
39
|
-
> /etc/mediamtx/mediamtx.yml
|
|
40
24
|
|
|
41
25
|
# `docker exec` skips /ros_entrypoint.sh — source ROS for the login shells dev:back runs.
|
|
42
26
|
RUN echo ". /opt/ros/${ROS_DISTRO}/setup.sh" > /etc/profile.d/ros.sh
|
|
@@ -15,24 +15,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
15
15
|
# cpp SDK shared lib must be present for the python module to load. Install the
|
|
16
16
|
# rainbow cpp SDK (apt, pinned to the robot's version) here once it exists.
|
|
17
17
|
|
|
18
|
-
# MediaMTX — same version/ports as the Muscat runtime (rby2 scripts/backend/runtime/mediamtx.yml),
|
|
19
|
-
# plus webrtcAdditionalHosts so a browser on the dev host reaches the WebRTC media port.
|
|
20
|
-
ARG MEDIAMTX_VERSION=1.12.3
|
|
21
|
-
RUN set -eux; \
|
|
22
|
-
apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/*; \
|
|
23
|
-
case "$(uname -m)" in x86_64) a=amd64 ;; aarch64|arm64) a=arm64 ;; *) exit 1 ;; esac; \
|
|
24
|
-
curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${a}.tar.gz" \
|
|
25
|
-
| tar -xz -C /usr/local/bin mediamtx; \
|
|
26
|
-
mkdir -p /etc/mediamtx; \
|
|
27
|
-
printf '%s\n' \
|
|
28
|
-
'logLevel: warn' \
|
|
29
|
-
'rtsp: yes' 'rtspAddress: :8554' \
|
|
30
|
-
'webrtc: yes' 'webrtcAddress: :8889' \
|
|
31
|
-
'webrtcLocalUDPAddress: :8189' 'webrtcLocalTCPAddress: :8189' \
|
|
32
|
-
'webrtcAdditionalHosts: [127.0.0.1]' 'webrtcAllowOrigin: "*"' \
|
|
33
|
-
'hls: no' 'rtmp: no' 'srt: no' 'api: no' \
|
|
34
|
-
'paths:' ' all_others:' ' source: publisher' \
|
|
35
|
-
> /etc/mediamtx/mediamtx.yml
|
|
36
18
|
|
|
37
19
|
WORKDIR /plugin
|
|
38
20
|
|
package/cli/src/cpp.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Used by dev:back (interactive dev loop) and build (export the binary into the ZIP).
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
|
-
import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
|
|
6
|
+
import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile, buildCacheFlags } from "./util.mjs";
|
|
7
7
|
import { syncCppIde } from "./cppIde.mjs";
|
|
8
8
|
|
|
9
9
|
export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
|
|
@@ -35,7 +35,7 @@ async function buildImage(image, platform, root) {
|
|
|
35
35
|
const buildArgs = [...(sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : []), ...imageKeyLabel(key)];
|
|
36
36
|
const r = spawnSync(
|
|
37
37
|
"docker",
|
|
38
|
-
["build", ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
38
|
+
["build", ...buildCacheFlags(), ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
39
39
|
{ stdio: "inherit" },
|
|
40
40
|
);
|
|
41
41
|
if (r.status !== 0) throw new Error("docker build failed.");
|
package/cli/src/dev.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// muscat-pk dev — run the
|
|
1
|
+
// muscat-pk dev — run the local Muscat runtime (gateway, MediaMTX, common), dev:ui, and dev:back together.
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { connect as netConnect } from "node:net";
|
|
@@ -6,8 +6,7 @@ import { resolve } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { runDevUi } from "./devUi.mjs";
|
|
8
8
|
import { runDevBack, stopDevBackContainers } from "./devBack.mjs";
|
|
9
|
-
import {
|
|
10
|
-
import { ensureLocalMuscat } from "./muscatLocal.mjs";
|
|
9
|
+
import { ensureLocalMuscat, GATEWAY_PORT, isPortInUse } from "./muscatLocal.mjs";
|
|
11
10
|
import { assertManifestPorts, assertManifestVariables } from "./manifest.mjs";
|
|
12
11
|
import { formatPortRanges } from "./ports.mjs";
|
|
13
12
|
import {
|
|
@@ -19,7 +18,7 @@ import {
|
|
|
19
18
|
promptRobotConnection,
|
|
20
19
|
venvPython,
|
|
21
20
|
} from "./util.mjs";
|
|
22
|
-
import { ensureRemoteZenohEnv, isRemoteRobot, isWsl
|
|
21
|
+
import { ensureRemoteZenohEnv, isRemoteRobot, isWsl } from "./zenohDev.mjs";
|
|
23
22
|
|
|
24
23
|
const HOST_AGENT_PATH = fileURLToPath(new URL("./host_agent.py", import.meta.url));
|
|
25
24
|
const canAutoStartHostAgent = () => process.platform === "darwin" || isWsl();
|
|
@@ -100,7 +99,7 @@ async function maybeStartHostAgent(root, { robotIp, robotToken }) {
|
|
|
100
99
|
}
|
|
101
100
|
|
|
102
101
|
// advertiseHost (not host.docker.internal): a Docker Desktop container run
|
|
103
|
-
// under `dev`
|
|
102
|
+
// under `dev` shares the local Muscat container's netns (--network container:),
|
|
104
103
|
// which has no host-gateway alias configured — the host's real LAN IP
|
|
105
104
|
// always resolves regardless of netns mode.
|
|
106
105
|
const proxyEndpoint = `${advertiseHost}:${proxyPort}`;
|
|
@@ -124,41 +123,29 @@ export async function runDev(args = []) {
|
|
|
124
123
|
if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
|
|
125
124
|
|
|
126
125
|
const remoteRobot = isRemoteRobot(robotIp);
|
|
127
|
-
// No robot: a local Muscat (common) owns :10000, so the bridge below is skipped and
|
|
128
|
-
// dev:back joins the Muscat netns instead — same as against a `muscat-dev` container.
|
|
129
|
-
const localMuscat = remoteRobot
|
|
130
|
-
? null
|
|
131
|
-
: await ensureLocalMuscat().catch((e) => { log.warn(`local Muscat: ${e.message}`); return null; });
|
|
132
126
|
const hostAgent = await maybeStartHostAgent(root, { robotIp, robotToken });
|
|
133
127
|
const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT || "";
|
|
134
128
|
// No proxy (Linux, disabled, ...): fall back to the container reverse-dialing
|
|
135
|
-
// the robot itself —
|
|
129
|
+
// the robot itself — the local Muscat then publishes the zenoh port for it.
|
|
136
130
|
if (!proxyEndpoint) await ensureRemoteZenohEnv(robotIp, { log });
|
|
137
131
|
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
pluginPorts: usesHostNetwork() ? [] : portRanges,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
const muscat = bridgeStarted ? null : (localMuscat ?? localMuscatContainer());
|
|
132
|
+
// Robot or not, the local Muscat runtime is the gateway (:10000), MediaMTX and common; the
|
|
133
|
+
// backend joins its netns, so the plugin's declared ports are published from it too.
|
|
134
|
+
log.step("starting local Muscat (Docker) + dev:ui + dev:back");
|
|
135
|
+
const muscat = await ensureLocalMuscat({
|
|
136
|
+
pluginPorts: portRanges,
|
|
137
|
+
zenohPort: remoteRobot && !proxyEndpoint ? Number(process.env.MUSCAT_ZENOH_PORT) || null : null,
|
|
138
|
+
}).catch((e) => { log.warn(`local Muscat: ${e.message}`); return null; });
|
|
149
139
|
if (muscat) {
|
|
150
|
-
log.step(`local Muscat container '${muscat}' owns :${
|
|
140
|
+
log.step(`local Muscat container '${muscat}' owns :${GATEWAY_PORT} — backend joins its network (gateway, MediaMTX)`);
|
|
151
141
|
process.env.MUSCAT_PK_MUSCAT_CONTAINER = muscat;
|
|
152
|
-
} else if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (answer.toLowerCase() !== "y") return;
|
|
156
|
-
log.info("proceeding without bridge — backend will skip network sharing.");
|
|
142
|
+
} else if (await isPortInUse(GATEWAY_PORT)) {
|
|
143
|
+
// e.g. developing on the robot PC: its Muscat already serves the gateway; the backend reaches it via the host.
|
|
144
|
+
log.step(`:${GATEWAY_PORT} is already served (Muscat on this machine) — using it`);
|
|
157
145
|
} else {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (bridgeStarted.media) process.env.MUSCAT_PK_MEDIA = "1";
|
|
146
|
+
log.warn(`no local Muscat on :${GATEWAY_PORT} — the plugin UI cannot reach the backend.`);
|
|
147
|
+
const answer = await prompt(" Proceed anyway? [y/N]: ");
|
|
148
|
+
if (answer.toLowerCase() !== "y") return;
|
|
162
149
|
}
|
|
163
150
|
|
|
164
151
|
process.env.MUSCAT_PK_MANAGED_DEV = "1";
|
|
@@ -166,7 +153,6 @@ export async function runDev(args = []) {
|
|
|
166
153
|
const cleanup = () => {
|
|
167
154
|
if (hostAgent && hostAgent.exitCode === null) hostAgent.kill();
|
|
168
155
|
stopDevBackContainers();
|
|
169
|
-
if (bridgeStarted) stopBridge();
|
|
170
156
|
};
|
|
171
157
|
process.once("SIGINT", cleanup);
|
|
172
158
|
process.once("SIGTERM", cleanup);
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Every backend runs in a warm Docker container that matches the robot
|
|
3
3
|
// (Ubuntu 24.04), so dev behaves like production.
|
|
4
4
|
// python : Python 3.12 container, pip install, run main.py with watchfiles
|
|
5
|
-
// hot-reload. Shares the
|
|
5
|
+
// hot-reload. Shares the local Muscat container's netns (localhost:10000).
|
|
6
6
|
// cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
|
|
7
7
|
// restart on source changes.
|
|
8
8
|
// ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
|
|
@@ -13,7 +13,7 @@ import { isIP } from "node:net";
|
|
|
13
13
|
import { dirname, resolve } from "node:path";
|
|
14
14
|
import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
|
|
15
15
|
import { ensureRemoteZenohEnv, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
|
|
16
|
-
import { localMuscatContainer } from "./
|
|
16
|
+
import { localMuscatContainer } from "./muscatLocal.mjs";
|
|
17
17
|
import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
|
|
18
18
|
import {
|
|
19
19
|
formatPortRanges,
|
|
@@ -54,9 +54,9 @@ export function stopDevBackContainers() {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// Where the backend's 127.0.0.1 points. A local Muscat container (muscat-dev):
|
|
58
|
-
// netns so the gateway and MediaMTX resolve like on the robot.
|
|
59
|
-
// netns
|
|
57
|
+
// Where the backend's 127.0.0.1 points. A local Muscat container (muscat-pk-muscat, muscat-dev):
|
|
58
|
+
// join its netns so the gateway and MediaMTX resolve like on the robot. Otherwise its own
|
|
59
|
+
// netns, gateway via the host.
|
|
60
60
|
// IS_DEV Muscat (muscat-dev): common is a zenoh-router client and opens no 7447 listener,
|
|
61
61
|
// so the plugin must join the router too or `execute` queries never reach it.
|
|
62
62
|
function muscatZenohEnv(container) {
|
|
@@ -70,8 +70,6 @@ function backendNetwork(portRanges) {
|
|
|
70
70
|
const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
|
|
71
71
|
if (muscat) return { run: ["--network", `container:${muscat}`], env: muscatZenohEnv(muscat), shared: true };
|
|
72
72
|
if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
|
|
73
|
-
const bridge = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
|
|
74
|
-
if (bridge) return { run: ["--network", `container:${bridge}`], env: [], shared: true };
|
|
75
73
|
return {
|
|
76
74
|
// host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
|
|
77
75
|
run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
|
|
@@ -117,13 +115,6 @@ async function serveRosPeers(root, pluginId, restart) {
|
|
|
117
115
|
});
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
// Our own MediaMTX, only when `dev` found its ports free (a local Muscat brings its own).
|
|
121
|
-
function startMediaServer(container) {
|
|
122
|
-
if (process.env.MUSCAT_PK_MEDIA !== "1") return;
|
|
123
|
-
spawnSync("docker", ["exec", "-d", container, "mediamtx", "/etc/mediamtx/mediamtx.yml"], { stdio: "ignore" });
|
|
124
|
-
log.step("MediaMTX: publish rtsp://127.0.0.1:8554/<path> · play http://localhost:8889/<path>");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
118
|
// ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
|
|
128
119
|
// `muscat-pk new` already creates this up front (see new.mjs); this just
|
|
129
120
|
// refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
|
|
@@ -144,8 +135,8 @@ function syncIdeVenv(root) {
|
|
|
144
135
|
|
|
145
136
|
// ── python ────────────────────────────────────────────────────────────────────
|
|
146
137
|
// Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
|
|
147
|
-
// Under `muscat-pk dev`, it shares the
|
|
148
|
-
// reaches the
|
|
138
|
+
// Under `muscat-pk dev`, it shares the local Muscat container's netns, so the SDK
|
|
139
|
+
// reaches the plugin gateway at the default localhost:10000.
|
|
149
140
|
async function runPython(root, portRanges) {
|
|
150
141
|
// Independent of Docker readiness — must still happen if Docker isn't
|
|
151
142
|
// installed/running, since that's exactly when local IDE tooling matters most.
|
|
@@ -164,7 +155,7 @@ async function runPython(root, portRanges) {
|
|
|
164
155
|
// Zenoh mesh, so it speaks a byte protocol to the host-side proxy instead.
|
|
165
156
|
const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT ?? "";
|
|
166
157
|
// Native Linux (not WSL, not Docker Desktop) has real host networking: the
|
|
167
|
-
// container shares the host's network stack, so it reaches the
|
|
158
|
+
// container shares the host's network stack, so it reaches the gateway at
|
|
168
159
|
// localhost:10000 and the robot's Zenoh router / reverse-dial path directly
|
|
169
160
|
// — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
|
|
170
161
|
const net = backendNetwork(portRanges);
|
|
@@ -205,7 +196,6 @@ async function runPython(root, portRanges) {
|
|
|
205
196
|
{ stdio: "inherit" },
|
|
206
197
|
);
|
|
207
198
|
if (up.status !== 0) throw new Error("failed to start dev container.");
|
|
208
|
-
startMediaServer(container);
|
|
209
199
|
const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
|
|
210
200
|
|
|
211
201
|
log.step("pip install requirements + watchfiles (in container)…");
|
|
@@ -423,7 +413,7 @@ runpy.run_path("/plugin/py/main.py", run_name="__main__")
|
|
|
423
413
|
PY`]);
|
|
424
414
|
|
|
425
415
|
if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
|
|
426
|
-
if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/
|
|
416
|
+
if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/gateway access)");
|
|
427
417
|
if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
|
|
428
418
|
else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
|
|
429
419
|
log.step("watchfiles → py/main.py (reload on any .py change in py/)");
|
|
@@ -503,7 +493,6 @@ async function runRos2(root, portRanges) {
|
|
|
503
493
|
{ stdio: "inherit" },
|
|
504
494
|
);
|
|
505
495
|
if (up.status !== 0) throw new Error("failed to start dev container.");
|
|
506
|
-
startMediaServer(container);
|
|
507
496
|
const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
|
|
508
497
|
|
|
509
498
|
// one-time deps + symlink build (edits to .py reflect without rebuilding)
|
|
@@ -596,7 +585,6 @@ async function runCpp(root, portRanges) {
|
|
|
596
585
|
{ stdio: "inherit" },
|
|
597
586
|
);
|
|
598
587
|
if (up.status !== 0) throw new Error("failed to start dev container.");
|
|
599
|
-
startMediaServer(container);
|
|
600
588
|
const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
|
|
601
589
|
|
|
602
590
|
const build = () => {
|
package/cli/src/muscatLocal.mjs
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
// Local Muscat
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Local Muscat runtime for `dev` / `dev:ui`, with or without a robot. It is the plugin gateway
|
|
2
|
+
// (:10000, plugin-socketio — same Socket.IO protocol as on the robot), MediaMTX, and `common`:
|
|
3
|
+
// preview-host hits http://localhost:3000/common/* (baked into the nexus bundle), so program
|
|
4
|
+
// preview works with no front-end change. The backend container joins its netns. We reuse any
|
|
5
|
+
// Muscat already publishing :10000 (e.g. a `muscat` / `muscat-dev` container) and only otherwise
|
|
6
|
+
// start our own: the S3 runtime image with just the `common` service.
|
|
6
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { createServer } from "node:net";
|
|
7
9
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
10
|
import { homedir } from "node:os";
|
|
9
11
|
import { resolve } from "node:path";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { canSkipImageBuild, hasBin, log } from "./util.mjs";
|
|
12
|
+
import { mediaPortFlags, MEDIA_PORTS, publishPortFlags } from "./ports.mjs";
|
|
13
|
+
import { canSkipImageBuild, hasBin, log, buildCacheFlags } from "./util.mjs";
|
|
13
14
|
|
|
14
15
|
export const MUSCAT_CONTAINER = "muscat-pk-muscat";
|
|
16
|
+
// Ports published for the backend (it shares this container's netns); a different set recreates it.
|
|
17
|
+
const PORTS_LABEL = "com.rainbow-robotics.muscat-pk.ports";
|
|
15
18
|
const IMAGE = "muscat-pk-runtime";
|
|
16
19
|
const S3 = process.env.MUSCAT_PK_MUSCAT_S3 ?? "https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat/main";
|
|
17
20
|
const CACHE = resolve(homedir(), ".muscat-pk", "muscat");
|
|
18
21
|
const API_PORT = 3000;
|
|
19
|
-
const GATEWAY_PORT = 10000;
|
|
22
|
+
export const GATEWAY_PORT = 10000;
|
|
20
23
|
// The bin runs inside Docker, so follow the Docker engine's arch, not this Node's
|
|
21
24
|
// (Rosetta/x64 Node on an Apple Silicon host would otherwise pick amd64).
|
|
22
25
|
const dockerArch = () => {
|
|
@@ -72,7 +75,7 @@ async function ensureRuntimeImage(total) {
|
|
|
72
75
|
log.step(`docker build -t ${image} (first run downloads MongoDB/InfluxDB/ROS base — slow, then cached)…`);
|
|
73
76
|
const r = spawnSync(
|
|
74
77
|
"docker",
|
|
75
|
-
["build", "-t", image, "-f", resolve(dir, "Dockerfile.runtime"), "--label", "com.rainbow-robotics.muscat-pk=true", dir],
|
|
78
|
+
["build", ...buildCacheFlags(), "-t", image, "-f", resolve(dir, "Dockerfile.runtime"), "--label", "com.rainbow-robotics.muscat-pk=true", dir],
|
|
76
79
|
{ stdio: "inherit" },
|
|
77
80
|
);
|
|
78
81
|
if (r.status !== 0) throw new Error("docker build failed (Muscat runtime image).");
|
|
@@ -106,16 +109,41 @@ async function waitForApi(timeoutMs = 180_000) {
|
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
// Is a host TCP port already bound?
|
|
113
|
+
export function isPortInUse(port) {
|
|
114
|
+
return new Promise((res) => {
|
|
115
|
+
const srv = createServer();
|
|
116
|
+
srv.once("error", (err) => res(err.code === "EADDRINUSE"));
|
|
117
|
+
srv.once("listening", () => srv.close(() => res(false)));
|
|
118
|
+
srv.listen(port, "0.0.0.0");
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A running Muscat container (ours or e.g. muscat-dev) that owns host port 10000, or null. */
|
|
123
|
+
export function localMuscatContainer() {
|
|
124
|
+
const r = spawnSync("docker", ["ps", "--filter", `publish=${GATEWAY_PORT}`, "--format", "{{.Names}}"], { encoding: "utf8" });
|
|
125
|
+
return (r.stdout || "").split("\n").map((n) => n.trim()).find(Boolean) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const containerLabel = (name, label) =>
|
|
129
|
+
spawnSync("docker", ["inspect", "-f", `{{index .Config.Labels "${label}"}}`, name], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
130
|
+
|
|
109
131
|
/**
|
|
110
132
|
* Make sure a Muscat answers on localhost:3000 / :10000. Returns the container name,
|
|
111
133
|
* or null when one can't be started (no Docker, foreign process on the ports).
|
|
112
134
|
* The container is left running across runs — stop it with `docker rm -f muscat-pk-muscat`.
|
|
113
135
|
*/
|
|
114
|
-
export async function ensureLocalMuscat() {
|
|
136
|
+
export async function ensureLocalMuscat({ pluginPorts = [], zenohPort = null } = {}) {
|
|
115
137
|
if (process.env.MUSCAT_PK_NO_LOCAL_MUSCAT === "1") return null;
|
|
138
|
+
const portsKey = [...pluginPorts, ...(zenohPort ? [`zenoh:${zenohPort}`] : [])].join(",");
|
|
116
139
|
const existing = hasBin("docker") ? localMuscatContainer() : null;
|
|
117
|
-
if (existing) {
|
|
140
|
+
if (existing === MUSCAT_CONTAINER && containerLabel(existing, PORTS_LABEL) !== portsKey) {
|
|
141
|
+
log.step(`recreating local Muscat to publish ${portsKey || "no extra"} port(s)`);
|
|
142
|
+
spawnSync("docker", ["rm", "-f", MUSCAT_CONTAINER], { stdio: "ignore" });
|
|
143
|
+
} else if (existing) {
|
|
118
144
|
log.step(`local Muscat '${existing}' already on :${GATEWAY_PORT} — reusing it`);
|
|
145
|
+
if (existing !== MUSCAT_CONTAINER && portsKey)
|
|
146
|
+
log.warn(`'${existing}' is not managed by muscat-pk — port(s) ${portsKey} are not published from it.`);
|
|
119
147
|
return existing;
|
|
120
148
|
}
|
|
121
149
|
if (!hasBin("docker")) {
|
|
@@ -139,11 +167,16 @@ export async function ensureLocalMuscat() {
|
|
|
139
167
|
|
|
140
168
|
let media = true;
|
|
141
169
|
for (const port of MEDIA_PORTS) if (await isPortInUse(port)) media = false;
|
|
170
|
+
// A busy plugin port would make `docker run` fail with a raw daemon error — skip it and say so.
|
|
171
|
+
const busy = [];
|
|
172
|
+
for (const port of pluginPorts) if (await isPortInUse(port)) busy.push(port);
|
|
173
|
+
if (busy.length) log.warn(`plugin port(s) already in use on the host: ${busy.join(", ")} — not published (the backend still listens inside).`);
|
|
174
|
+
const published = pluginPorts.filter((port) => !busy.includes(port));
|
|
142
175
|
|
|
143
176
|
spawnSync("docker", ["rm", "-f", MUSCAT_CONTAINER], { stdio: "ignore" });
|
|
144
177
|
log.step(`starting local Muscat (common ${commonVersion}) → http://localhost:${API_PORT}/common/docs`);
|
|
145
178
|
const r = spawnSync("docker", [
|
|
146
|
-
"run", "-d", "--name", MUSCAT_CONTAINER, "--label", "com.rainbow-robotics.muscat-pk=true",
|
|
179
|
+
"run", "-d", "--name", MUSCAT_CONTAINER, "--label", "com.rainbow-robotics.muscat-pk=true", "--label", `${PORTS_LABEL}=${portsKey}`,
|
|
147
180
|
"-e", "RB_ENABLED_SERVICES=common",
|
|
148
181
|
"-e", "RB_RUNTIME_ENABLE_ZENOH_BRIDGE=0",
|
|
149
182
|
"-v", `${commonDir}:/app/services/common`,
|
|
@@ -151,10 +184,15 @@ export async function ensureLocalMuscat() {
|
|
|
151
184
|
"-p", `127.0.0.1:${API_PORT}:${API_PORT}`,
|
|
152
185
|
"-p", `127.0.0.1:${GATEWAY_PORT}:${GATEWAY_PORT}`,
|
|
153
186
|
...(media ? mediaPortFlags() : []),
|
|
187
|
+
...publishPortFlags(published),
|
|
188
|
+
// The robot reverse-dials the backend's Zenoh session from the LAN, so not loopback-only.
|
|
189
|
+
...(zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : []),
|
|
154
190
|
image,
|
|
155
191
|
], { stdio: ["ignore", "ignore", "inherit"] });
|
|
156
192
|
if (r.status !== 0) throw new Error("failed to start the local Muscat container.");
|
|
157
193
|
await waitForApi();
|
|
158
194
|
log.step(`local Muscat ready — keeps running between runs; stop with: docker rm -f ${MUSCAT_CONTAINER}`);
|
|
195
|
+
if (published.length) log.step(`plugin backend ports published on 127.0.0.1: ${published.join(", ")}`);
|
|
196
|
+
if (zenohPort) log.step(`Zenoh dev peer port published on :${zenohPort}`);
|
|
159
197
|
return MUSCAT_CONTAINER;
|
|
160
198
|
}
|
package/cli/src/ports.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export function mediaPortFlags() {
|
|
|
16
16
|
export const RESERVED_BACKEND_PORTS = new Map([
|
|
17
17
|
[502, "Modbus TCP"],
|
|
18
18
|
[4840, "OPC-UA"],
|
|
19
|
-
[10000, "
|
|
19
|
+
[10000, "Muscat plugin gateway"],
|
|
20
20
|
[2222, "EtherNet/IP Implicit"],
|
|
21
21
|
[30004, "RTDE"],
|
|
22
22
|
[34964, "ProfiNET"],
|
package/cli/src/python.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
|
-
import { canSkipImageBuild, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
|
|
6
|
+
import { canSkipImageBuild, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile, buildCacheFlags } from "./util.mjs";
|
|
7
7
|
|
|
8
8
|
export const PYTHON_DEV_IMAGE = "muscat-pk-python-dev";
|
|
9
9
|
|
|
@@ -25,7 +25,7 @@ export async function ensureImage(image = PYTHON_DEV_IMAGE, { platform = null }
|
|
|
25
25
|
const platformFlags = platform ? ["--platform", platform] : [];
|
|
26
26
|
const r = spawnSync(
|
|
27
27
|
"docker",
|
|
28
|
-
["build", ...platformFlags, ...imageKeyLabel(key), "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
28
|
+
["build", ...buildCacheFlags(), ...platformFlags, ...imageKeyLabel(key), "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
29
29
|
{ stdio: "inherit" },
|
|
30
30
|
);
|
|
31
31
|
if (r.status !== 0) throw new Error("docker build failed.");
|
package/cli/src/ros2.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync, readdirSync } from "node:fs";
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
|
-
import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
|
|
6
|
+
import { canSkipImageBuild, cppSdkVersion, pluginRootOrNull, imageKey, imageKeyLabel, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile, buildCacheFlags } from "./util.mjs";
|
|
7
7
|
import { syncCppIde } from "./cppIde.mjs";
|
|
8
8
|
|
|
9
9
|
export const ROS2_DEV_IMAGE = "muscat-pk-ros2-dev";
|
|
@@ -50,7 +50,7 @@ async function buildImage(image, platform, root) {
|
|
|
50
50
|
const buildArgs = [...(sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : []), ...imageKeyLabel(key)];
|
|
51
51
|
const r = spawnSync(
|
|
52
52
|
"docker",
|
|
53
|
-
["build", ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
53
|
+
["build", ...buildCacheFlags(), ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
|
|
54
54
|
{ stdio: "inherit" },
|
|
55
55
|
);
|
|
56
56
|
if (r.status !== 0) throw new Error("docker build failed.");
|
package/cli/src/util.mjs
CHANGED
|
@@ -269,12 +269,12 @@ export const log = {
|
|
|
269
269
|
error: (...m) => console.error(color.red("✖"), ...m.map(s => color.red(s))),
|
|
270
270
|
};
|
|
271
271
|
|
|
272
|
-
// dev / dev:back / build run the plugin back end, the
|
|
272
|
+
// dev / dev:back / build run the plugin back end, the local Muscat runtime, and cpp/ros2
|
|
273
273
|
// compilation in Docker. Show a clear install hint when Docker is missing.
|
|
274
274
|
export function ensureDocker() {
|
|
275
275
|
if (hasBin("docker")) return;
|
|
276
276
|
log.warn("Docker not found.");
|
|
277
|
-
log.warn(" muscat-pk runs the plugin back end, the
|
|
277
|
+
log.warn(" muscat-pk runs the plugin back end, the local Muscat runtime, and cpp/ros2 builds in Docker.");
|
|
278
278
|
log.warn(" Install Docker, then retry: https://docs.docker.com/get-docker/");
|
|
279
279
|
throw new Error("Docker is required for dev / dev:back / build.");
|
|
280
280
|
}
|
|
@@ -323,6 +323,10 @@ export function imageKey(dockerfile, extra = "") {
|
|
|
323
323
|
return createHash("sha256").update(readFileSync(dockerfile)).update(extra).digest("hex").slice(0, 16);
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
// MUSCAT_PK_NO_BUILD_CACHE=1 (CI): build images without Docker's layer cache, so an apt/PyPI/GitHub
|
|
327
|
+
// change that breaks a fresh build fails the test too instead of hiding behind a cached layer.
|
|
328
|
+
export const buildCacheFlags = () => (process.env.MUSCAT_PK_NO_BUILD_CACHE === "1" ? ["--no-cache"] : []);
|
|
329
|
+
|
|
326
330
|
export function imageKeyLabel(key) {
|
|
327
331
|
return ["--label", `${IMAGE_KEY_LABEL}=${key}`];
|
|
328
332
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"generatedAt":
|
|
1
|
+
{"generatedAt":1792588886374,"userSince":1750932757926,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":true,"refCount":0,"testPackages":{"@vitest/browser":"3.2.4","@vitest/coverage-v8":"3.2.4","playwright":"1.59.1","vitest":"3.2.4"},"hasRouterPackage":false,"monorepo":"Turborepo","packageManager":{"type":"yarn","version":"berry","agent":"yarn@berry"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","portableStoriesFileCount":0,"applicationFileCount":0,"storybookVersion":"8.6.18","storybookVersionSpecifier":"^8.6.4","language":"typescript","storybookPackages":{"@chromatic-com/storybook":{"version":"3.2.7"},"@storybook/addon-essentials":{"version":"8.6.18"},"@storybook/addon-links":{"version":"8.6.18"},"@storybook/addon-onboarding":{"version":"8.6.18"},"@storybook/addon-themes":{"version":"8.6.18"},"@storybook/blocks":{"version":"8.6.18"},"@storybook/experimental-addon-test":{"version":"8.6.18"},"@storybook/react":{"version":"8.6.18"},"@storybook/react-vite":{"version":"8.6.18"},"@storybook/test":{"version":"8.6.18"},"@storybook/theming":{"version":"8.6.18"},"eslint-plugin-storybook":{"version":"0.11.6"},"storybook":{"version":"8.6.18"},"storybook-addon-react-docgen":{"version":"1.2.44"}},"addons":{"$SNIP/node_modules/@storybook/addon-links":{"version":null},"$SNIP/node_modules/@storybook/addon-essentials":{"version":null},"$SNIP/node_modules/@chromatic-com/storybook":{"version":null},"$SNIP/node_modules/@storybook/addon-themes":{"version":null},"$SNIP/node_modules/@storybook/experimental-addon-test":{"version":null}}}
|
|
@@ -307,21 +307,10 @@ export default function App() {
|
|
|
307
307
|
|
|
308
308
|
## Developing directly on the robot PC
|
|
309
309
|
|
|
310
|
-
If you're running `muscat-pk dev` **on the robot PC itself**, Muscat
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
```
|
|
315
|
-
⚠ port 10000 is already in use — can't start Socket.IO bridge.
|
|
316
|
-
Proceed without Socket.IO bridge? [y/N]:
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
Answer **y**. Your plugin backend will connect to the Muscat bridge that's already
|
|
320
|
-
running instead of starting a new one — exactly what you want when developing on the
|
|
321
|
-
robot.
|
|
322
|
-
|
|
323
|
-
> If you answer **n** (or just press Enter), the CLI exits so you can free the port
|
|
324
|
-
> manually and retry.
|
|
310
|
+
If you're running `muscat-pk dev` **on the robot PC itself**, Muscat already serves the
|
|
311
|
+
plugin gateway on port 10000. The CLI sees that, does not start its own local Muscat, and
|
|
312
|
+
your plugin backend connects to the robot's gateway — exactly what you want when
|
|
313
|
+
developing on the robot.
|
|
325
314
|
|
|
326
315
|
---
|
|
327
316
|
|
|
@@ -329,8 +318,7 @@ robot.
|
|
|
329
318
|
|
|
330
319
|
`manifest.json` chooses one backend runtime. `muscat-pk dev` runs backend code in
|
|
331
320
|
a Docker container matching the robot environment. Backend code should contain
|
|
332
|
-
plugin logic only; it should not start
|
|
333
|
-
plugin gateway.
|
|
321
|
+
plugin logic only; it should not start its own copy of the plugin gateway.
|
|
334
322
|
|
|
335
323
|
### Which backend pattern? (depends on the contribution point)
|
|
336
324
|
|
|
@@ -488,10 +476,10 @@ Known limits:
|
|
|
488
476
|
- The MQTT broker echoes to the sender: a plugin that `emit`s and `subscribe`s the same topic receives its own push over `mqtt`. Drop `mqtt` from `explicit_events` or use separate topics. (TCP is filtered by `sender_id`.)
|
|
489
477
|
- `on` and `subscribe` may share a name — they live in separate tables.
|
|
490
478
|
|
|
491
|
-
During `muscat-pk dev`
|
|
492
|
-
|
|
493
|
-
`common
|
|
494
|
-
`common
|
|
479
|
+
During `muscat-pk dev` the local Muscat runtime takes the `router.*` registrations
|
|
480
|
+
on its gateway (port `10000`), and its `common` (port `3000`) routes
|
|
481
|
+
`common/plugin/...` to them like on the robot — try
|
|
482
|
+
`curl http://localhost:3000/common/plugin/<plugin-id>/<path>`. After restarting the plugin process,
|
|
495
483
|
external calls may return 504 for up to about a minute (until the gateway notices
|
|
496
484
|
the old socket is gone), then 404 — that is expected.
|
|
497
485
|
|
|
@@ -773,7 +761,7 @@ sudo apt update && sudo apt install rainbow-rb-sdk-dev
|
|
|
773
761
|
- CMake: `find_package(rb_sdk REQUIRED)` + `target_link_libraries(<target> rb_sdk::rb_sdk)` (C++20). pkg-config: `rb_sdk`.
|
|
774
762
|
- Headers: `<rainbow/rb_sdk/manipulate/rb_manipulate_sdk.hpp>` (`RBManipulateSDK`),
|
|
775
763
|
`<rainbow/rb_sdk/program/plugin_program_context.hpp>` (`rb_plugin::PluginProgramContext`, for `program.panel` commands — the header's top comment has a full `main()` example).
|
|
776
|
-
- The binary is linked **dynamically**; `muscat-pk build` copies the matching `librb_sdk.so` into `dist/<arch>/lib/` (the robot has no SDK installed), so each plugin keeps the SDK version it was built with.
|
|
764
|
+
- The binary is linked **dynamically**; `muscat-pk build` copies the matching `librb_sdk.so` into `dist/<arch>/lib/` (the robot has no SDK installed), so each plugin keeps the SDK version it was built with. The version is the one pinned in `CMakeLists.txt` (`find_package(rb_sdk <version> EXACT REQUIRED)`); change it with `muscat-pk sdk update <version>`.
|
|
777
765
|
- UI ↔ backend messaging works from C++ too: `rb_plugin::RBPluginCall` in `<rainbow/rb_sdk/plugin/rb_plugin.hpp>` (see the scaffolded `cpp/main.cpp`).
|
|
778
766
|
|
|
779
767
|
### ROS2 (rclpy)
|
|
@@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
|
7
7
|
# Rainbow Robotics C++ SDK — provided by the apt package rainbow-rb-sdk-dev
|
|
8
8
|
# (pre-installed in the muscat-pk dev container). The version here is the one the
|
|
9
9
|
# dev container installs; change it with `muscat-pk sdk update <version>`.
|
|
10
|
-
find_package(rb_sdk 0.
|
|
10
|
+
find_package(rb_sdk 0.10.2 EXACT REQUIRED)
|
|
11
11
|
|
|
12
12
|
# The executable name must match runtime.entry in manifest.json.
|
|
13
13
|
add_executable(__PLUGIN_SLUG__ main.cpp)
|
|
@@ -307,21 +307,10 @@ export default function App() {
|
|
|
307
307
|
|
|
308
308
|
## Developing directly on the robot PC
|
|
309
309
|
|
|
310
|
-
If you're running `muscat-pk dev` **on the robot PC itself**, Muscat
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
```
|
|
315
|
-
⚠ port 10000 is already in use — can't start Socket.IO bridge.
|
|
316
|
-
Proceed without Socket.IO bridge? [y/N]:
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
Answer **y**. Your plugin backend will connect to the Muscat bridge that's already
|
|
320
|
-
running instead of starting a new one — exactly what you want when developing on the
|
|
321
|
-
robot.
|
|
322
|
-
|
|
323
|
-
> If you answer **n** (or just press Enter), the CLI exits so you can free the port
|
|
324
|
-
> manually and retry.
|
|
310
|
+
If you're running `muscat-pk dev` **on the robot PC itself**, Muscat already serves the
|
|
311
|
+
plugin gateway on port 10000. The CLI sees that, does not start its own local Muscat, and
|
|
312
|
+
your plugin backend connects to the robot's gateway — exactly what you want when
|
|
313
|
+
developing on the robot.
|
|
325
314
|
|
|
326
315
|
---
|
|
327
316
|
|
|
@@ -329,8 +318,7 @@ robot.
|
|
|
329
318
|
|
|
330
319
|
`manifest.json` chooses one backend runtime. `muscat-pk dev` runs backend code in
|
|
331
320
|
a Docker container matching the robot environment. Backend code should contain
|
|
332
|
-
plugin logic only; it should not start
|
|
333
|
-
plugin gateway.
|
|
321
|
+
plugin logic only; it should not start its own copy of the plugin gateway.
|
|
334
322
|
|
|
335
323
|
### Which backend pattern? (depends on the contribution point)
|
|
336
324
|
|
|
@@ -367,7 +355,7 @@ don't install anything locally.
|
|
|
367
355
|
|
|
368
356
|
```cmake
|
|
369
357
|
# cpp/CMakeLists.txt — the version is the SDK the dev container installs (muscat-pk sdk update <version>)
|
|
370
|
-
find_package(rb_sdk 0.
|
|
358
|
+
find_package(rb_sdk 0.10.2 EXACT REQUIRED)
|
|
371
359
|
add_executable(__PLUGIN_SLUG__ main.cpp) # name must equal runtime.entry
|
|
372
360
|
target_link_libraries(__PLUGIN_SLUG__ PRIVATE rb_sdk::rb_sdk)
|
|
373
361
|
```
|
|
@@ -476,8 +464,9 @@ curl -s -X POST http://<robot>/common/plugin/<plugin_id>/hello \
|
|
|
476
464
|
| Plugin did not answer within 10 s | 504 |
|
|
477
465
|
| `common` ↔ gateway not connected | 503 |
|
|
478
466
|
|
|
479
|
-
During `muscat-pk dev` the local
|
|
480
|
-
|
|
467
|
+
During `muscat-pk dev` the local Muscat runtime takes `router()` registrations on its
|
|
468
|
+
gateway and its `common` routes external calls to them like on the robot
|
|
469
|
+
(`curl http://localhost:3000/common/plugin/<plugin-id>/<path>`). After
|
|
481
470
|
restarting the plugin, external calls may return 504 for about a minute, then 404.
|
|
482
471
|
|
|
483
472
|
#### Program command backend (`program.panel`)
|
|
@@ -831,10 +820,10 @@ falling back to `README.md` if none matches.
|
|
|
831
820
|
## C++ SDK & dependencies
|
|
832
821
|
|
|
833
822
|
The dev container has the C++ SDK (`rainbow-rb-sdk-dev`) pre-installed — no local
|
|
834
|
-
install needed. Pick a different
|
|
823
|
+
install needed. The SDK version is the one pinned in `CMakeLists.txt` (`find_package(rb_sdk <version> EXACT REQUIRED)`). Pick a different one with:
|
|
835
824
|
|
|
836
825
|
```bash
|
|
837
|
-
|
|
826
|
+
muscat-pk sdk update <version>
|
|
838
827
|
```
|
|
839
828
|
|
|
840
829
|
`muscat-pk build` copies the matching `librb_sdk.so` next to your binary
|
|
@@ -10,7 +10,7 @@ find_package(rclcpp REQUIRED)
|
|
|
10
10
|
# Rainbow Robotics C++ SDK (apt: rainbow-rb-sdk-dev, pre-installed in the dev
|
|
11
11
|
# container). The version here is the one the dev container installs; change it
|
|
12
12
|
# with `muscat-pk sdk update <version>`.
|
|
13
|
-
find_package(rb_sdk 0.
|
|
13
|
+
find_package(rb_sdk 0.10.2 EXACT REQUIRED)
|
|
14
14
|
|
|
15
15
|
add_executable(__PLUGIN_SLUG___node src/node.cpp)
|
|
16
16
|
ament_target_dependencies(__PLUGIN_SLUG___node rclcpp)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rainbow-robotics/muscat-plugin-kit",
|
|
3
|
-
"version": "1.3.8-dev.
|
|
3
|
+
"version": "1.3.8-dev.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20"
|
|
@@ -53,10 +53,9 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
56
|
-
"@rainbow-robotics/plugin-ui": "1.3.8-dev.
|
|
57
|
-
"@rainbow-robotics/preview-host": "1.3.8-dev.
|
|
56
|
+
"@rainbow-robotics/plugin-ui": "1.3.8-dev.2",
|
|
57
|
+
"@rainbow-robotics/preview-host": "1.3.8-dev.2",
|
|
58
58
|
"postcss": "^8.5.8",
|
|
59
|
-
"socket.io": "^4.8.1",
|
|
60
59
|
"typescript": "^5.8.0",
|
|
61
60
|
"vite-plugin-css-injected-by-js": "^3.5.2",
|
|
62
61
|
"zod": "^3.25.0"
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# Socket.IO bridge container — the frontend (preview-host) and the backend
|
|
2
|
-
# container rendezvous here during `muscat-pk dev`.
|
|
3
|
-
# - frontend (host) → published port localhost:10000
|
|
4
|
-
# - backend container → shares this container's netns (--network container:),
|
|
5
|
-
# so it reaches the bridge at localhost:10000 too
|
|
6
|
-
# Build context is the `cli/` dir so it can copy src/socketBridge.mjs.
|
|
7
|
-
FROM node:20-slim
|
|
8
|
-
LABEL com.rainbow-robotics.muscat-pk="true"
|
|
9
|
-
|
|
10
|
-
WORKDIR /bridge
|
|
11
|
-
COPY docker/bridge-package.json package.json
|
|
12
|
-
RUN npm install --omit=dev --no-audit --no-fund
|
|
13
|
-
|
|
14
|
-
COPY src/socketBridge.mjs ./socketBridge.mjs
|
|
15
|
-
|
|
16
|
-
EXPOSE 10000
|
|
17
|
-
CMD ["node", "socketBridge.mjs"]
|
package/cli/src/bridge.mjs
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
// Socket.IO bridge container lifecycle for `muscat-pk dev`.
|
|
2
|
-
// The bridge runs in Docker and publishes port 10000 to the host:
|
|
3
|
-
// - frontend (preview-host, on the host) connects to localhost:10000
|
|
4
|
-
// - the backend container joins via `--network container:<bridge>` and reaches
|
|
5
|
-
// it at localhost:10000 too — no host.docker.internal needed (robust cross-OS).
|
|
6
|
-
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { createServer, connect } from "node:net";
|
|
8
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { dirname, resolve } from "node:path";
|
|
11
|
-
import { canSkipImageBuild, hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
|
|
12
|
-
import { MEDIA_PORTS, mediaPortFlags } from "./ports.mjs";
|
|
13
|
-
import { usesHostNetwork } from "./zenohDev.mjs";
|
|
14
|
-
|
|
15
|
-
export const BRIDGE_IMAGE = "muscat-pk-bridge";
|
|
16
|
-
export const BRIDGE_CONTAINER = "muscat-pk-bridge-run";
|
|
17
|
-
export const BRIDGE_PORT = 10000;
|
|
18
|
-
|
|
19
|
-
// 컴파일된 바이너리에는 cli/ 트리가 없어 임베드 스냅샷에서 build context 를 tmp 에 복원한다.
|
|
20
|
-
const BRIDGE_CONTEXT_FILES = ["docker/Dockerfile.bridge", "docker/bridge-package.json", "src/socketBridge.mjs"];
|
|
21
|
-
|
|
22
|
-
async function resolveBridgeContext() {
|
|
23
|
-
const context = resolve(kitRoot, "cli");
|
|
24
|
-
const dockerfile = resolve(context, "docker/Dockerfile.bridge");
|
|
25
|
-
if (existsSync(dockerfile)) return { dockerfile, context };
|
|
26
|
-
|
|
27
|
-
const { TEMPLATES } = await import("./templates-embedded.mjs");
|
|
28
|
-
const dir = resolve(tmpdir(), "muscat-pk-bridge-context");
|
|
29
|
-
for (const key of BRIDGE_CONTEXT_FILES) {
|
|
30
|
-
const b64 = TEMPLATES[key];
|
|
31
|
-
if (!b64) throw new Error(`Embedded file missing: ${key}. Reinstall muscat-plugin-kit.`);
|
|
32
|
-
const file = resolve(dir, key);
|
|
33
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
34
|
-
writeFileSync(file, Buffer.from(b64, "base64"));
|
|
35
|
-
}
|
|
36
|
-
return { dockerfile: resolve(dir, "docker/Dockerfile.bridge"), context: dir };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function assertDockerReady() {
|
|
40
|
-
if (!hasBin("docker")) {
|
|
41
|
-
throw new Error("Docker not found. `muscat-pk dev` runs the Socket.IO bridge in a container.");
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Is a host TCP port already bound? (Catches another bridge or a stray process.)
|
|
46
|
-
export function isPortInUse(port) {
|
|
47
|
-
return new Promise((res) => {
|
|
48
|
-
const srv = createServer();
|
|
49
|
-
srv.once("error", (err) => res(err.code === "EADDRINUSE"));
|
|
50
|
-
srv.once("listening", () => srv.close(() => res(false)));
|
|
51
|
-
srv.listen(port, "0.0.0.0");
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Wait until the published port accepts a TCP connection (bridge is ready).
|
|
56
|
-
function waitForPort(port, timeoutMs = 15_000) {
|
|
57
|
-
const start = Date.now();
|
|
58
|
-
return new Promise((res, rej) => {
|
|
59
|
-
const tryOnce = () => {
|
|
60
|
-
const sock = connect(port, "127.0.0.1");
|
|
61
|
-
sock.once("connect", () => { sock.destroy(); res(); });
|
|
62
|
-
sock.once("error", () => {
|
|
63
|
-
sock.destroy();
|
|
64
|
-
if (Date.now() - start > timeoutMs) rej(new Error("bridge container did not become ready in time"));
|
|
65
|
-
else setTimeout(tryOnce, 300);
|
|
66
|
-
});
|
|
67
|
-
};
|
|
68
|
-
tryOnce();
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function ensureImage() {
|
|
73
|
-
if (canSkipImageBuild(BRIDGE_IMAGE)) {
|
|
74
|
-
log.step(`${BRIDGE_IMAGE} already built — skipping docker build`);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const { dockerfile, context } = await resolveBridgeContext();
|
|
78
|
-
log.step(`docker build -t ${BRIDGE_IMAGE} (first run is slow, then cached)…`);
|
|
79
|
-
const r = spawnSync("docker", ["build", "-t", BRIDGE_IMAGE, "-f", dockerfile, context], { stdio: "inherit" });
|
|
80
|
-
if (r.status !== 0) throw new Error("docker build failed (bridge image).");
|
|
81
|
-
pruneMuskatImages();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** A running Muscat container (e.g. muscat-dev) that owns host port 10000, or null. */
|
|
85
|
-
export function localMuscatContainer() {
|
|
86
|
-
const r = spawnSync("docker", ["ps", "--filter", `publish=${BRIDGE_PORT}`, "--format", "{{.Names}}"], { encoding: "utf8" });
|
|
87
|
-
return (r.stdout || "").split("\n").map((n) => n.trim()).find((n) => n && n !== BRIDGE_CONTAINER) ?? null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Build the image, preflight the port, and start the bridge container.
|
|
92
|
-
* Returns false if port 10000 was already in use (caller decides), else { media }:
|
|
93
|
-
* whether the MediaMTX ports are free for the backend's own MediaMTX.
|
|
94
|
-
*/
|
|
95
|
-
export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
|
|
96
|
-
assertDockerReady();
|
|
97
|
-
|
|
98
|
-
// remove our own stale container first, then check the host port is free
|
|
99
|
-
spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
|
|
100
|
-
if (await isPortInUse(BRIDGE_PORT)) return false;
|
|
101
|
-
|
|
102
|
-
// Publishing a busy port makes `docker run` fail with a raw daemon error —
|
|
103
|
-
// say which port and keep the bridge up instead.
|
|
104
|
-
const busy = [];
|
|
105
|
-
for (const port of pluginPorts) {
|
|
106
|
-
if (await isPortInUse(port)) busy.push(port);
|
|
107
|
-
}
|
|
108
|
-
if (busy.length > 0) {
|
|
109
|
-
log.warn(
|
|
110
|
-
`declared plugin port(s) already in use on the host: ${busy.join(", ")} — ` +
|
|
111
|
-
`not published (the backend still listens inside the container).`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
const publishable = pluginPorts.filter((port) => !busy.includes(port));
|
|
115
|
-
|
|
116
|
-
const mediaBusy = [];
|
|
117
|
-
for (const port of MEDIA_PORTS) {
|
|
118
|
-
if (await isPortInUse(port)) mediaBusy.push(port);
|
|
119
|
-
}
|
|
120
|
-
const media = mediaBusy.length === 0;
|
|
121
|
-
if (!media) log.warn(`MediaMTX port(s) already in use: ${mediaBusy.join(", ")} — video streaming disabled in dev.`);
|
|
122
|
-
|
|
123
|
-
await ensureImage();
|
|
124
|
-
|
|
125
|
-
log.step("starting Socket.IO bridge container…");
|
|
126
|
-
// The backend joins THIS container's netns (--network container:), and a
|
|
127
|
-
// netns can only publish ports when its owner starts — so the plugin's
|
|
128
|
-
// declared ports have to be published here, not on the backend container.
|
|
129
|
-
const portFlags = [
|
|
130
|
-
"-p", `127.0.0.1:${BRIDGE_PORT}:${BRIDGE_PORT}`,
|
|
131
|
-
...(zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : []),
|
|
132
|
-
...publishable.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]),
|
|
133
|
-
...(media && !usesHostNetwork() ? mediaPortFlags() : []),
|
|
134
|
-
];
|
|
135
|
-
const up = spawnSync(
|
|
136
|
-
"docker",
|
|
137
|
-
["run", "-d", "--name", BRIDGE_CONTAINER, ...portFlags, BRIDGE_IMAGE],
|
|
138
|
-
{ stdio: "inherit" },
|
|
139
|
-
);
|
|
140
|
-
if (up.status !== 0) throw new Error("failed to start bridge container.");
|
|
141
|
-
|
|
142
|
-
await waitForPort(BRIDGE_PORT);
|
|
143
|
-
log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
|
|
144
|
-
if (zenohPort) log.step(`Zenoh dev peer port published on :${zenohPort}`);
|
|
145
|
-
if (publishable.length > 0)
|
|
146
|
-
log.step(`plugin backend ports published on 127.0.0.1: ${publishable.join(", ")}`);
|
|
147
|
-
return { media };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function stopBridge() {
|
|
151
|
-
spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
|
|
152
|
-
}
|
package/cli/src/socketBridge.mjs
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { Server } from "socket.io";
|
|
5
|
-
|
|
6
|
-
// Self-contained logger — this module also runs standalone inside the bridge
|
|
7
|
-
// Docker container, where ./util.mjs is not available.
|
|
8
|
-
const log = {
|
|
9
|
-
step: (...m) => console.log("›", ...m),
|
|
10
|
-
warn: (...m) => console.warn("⚠", ...m),
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
|
|
14
|
-
|
|
15
|
-
export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
|
|
16
|
-
const httpServer = createServer();
|
|
17
|
-
const io = new Server(httpServer, { cors: { origin: "*" } });
|
|
18
|
-
|
|
19
|
-
// topic → socketId (serve_register로 등록된 queryable)
|
|
20
|
-
const queryables = new Map();
|
|
21
|
-
// call_id → { timer, resolve } (action → serve_call → serve_reply 대기)
|
|
22
|
-
const pending = new Map();
|
|
23
|
-
|
|
24
|
-
function cleanupSocket(socket) {
|
|
25
|
-
for (const [topic, sid] of queryables) {
|
|
26
|
-
if (sid === socket.id) queryables.delete(topic);
|
|
27
|
-
}
|
|
28
|
-
for (const [id, p] of pending) {
|
|
29
|
-
if (p.sid !== socket.id) continue;
|
|
30
|
-
clearTimeout(p.timer);
|
|
31
|
-
p.resolve(undefined);
|
|
32
|
-
pending.delete(id);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
io.on("connection", (socket) => {
|
|
37
|
-
socket.on("sub", async (data = {}) => {
|
|
38
|
-
if (!data.topic) return;
|
|
39
|
-
await socket.join(`t:${data.topic}`);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
socket.on("unsub", async (data = {}) => {
|
|
43
|
-
if (!data.topic) return;
|
|
44
|
-
await socket.leave(`t:${data.topic}`);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
socket.on("pub", (data = {}) => {
|
|
48
|
-
const { topic, payload } = data;
|
|
49
|
-
if (!topic) return;
|
|
50
|
-
io.to(`t:${topic}`).emit("msg", { topic, payload });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
socket.on("serve_register", (data = {}) => {
|
|
54
|
-
if (!data.topic) return;
|
|
55
|
-
queryables.set(data.topic, socket.id);
|
|
56
|
-
log.step(`serve registered: ${data.topic}`);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
socket.on("serve_unregister", (data = {}) => {
|
|
60
|
-
if (queryables.get(data.topic) === socket.id) queryables.delete(data.topic);
|
|
61
|
-
if (data.topic) log.step(`serve unregistered: ${data.topic}`);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
socket.on("serve_reply", (data = {}) => {
|
|
65
|
-
const p = pending.get(data.id);
|
|
66
|
-
if (!p) return;
|
|
67
|
-
clearTimeout(p.timer);
|
|
68
|
-
p.resolve(data.payload);
|
|
69
|
-
pending.delete(data.id);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
socket.on("action", (data = {}, ack) => {
|
|
73
|
-
const { topic, payload } = data;
|
|
74
|
-
const targetSid = queryables.get(topic);
|
|
75
|
-
const target = targetSid && io.sockets.sockets.get(targetSid);
|
|
76
|
-
if (!target) {
|
|
77
|
-
log.warn(`action has no handler: ${topic}`);
|
|
78
|
-
if (typeof ack === "function") ack({ error: `No handler registered for ${topic}` });
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
log.step(`action: ${topic}`);
|
|
82
|
-
const id = randomUUID();
|
|
83
|
-
const timer = setTimeout(() => {
|
|
84
|
-
pending.delete(id);
|
|
85
|
-
if (typeof ack === "function") ack(undefined);
|
|
86
|
-
}, DEFAULT_ACTION_TIMEOUT_MS);
|
|
87
|
-
pending.set(id, {
|
|
88
|
-
sid: targetSid,
|
|
89
|
-
timer,
|
|
90
|
-
resolve: (v) => { if (typeof ack === "function") ack(v); },
|
|
91
|
-
});
|
|
92
|
-
target.emit("serve_call", { id, topic, payload });
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
socket.on("disconnect", () => cleanupSocket(socket));
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
return new Promise((resolve) => {
|
|
99
|
-
httpServer.once("error", (err) => {
|
|
100
|
-
if (err.code === "EADDRINUSE") {
|
|
101
|
-
log.warn(`Socket.IO bridge already running on :${port} — skipping`);
|
|
102
|
-
io.close();
|
|
103
|
-
httpServer.close();
|
|
104
|
-
resolve({ close: () => {} });
|
|
105
|
-
} else {
|
|
106
|
-
throw err;
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
httpServer.listen(port, host, () => {
|
|
111
|
-
log.step(`Socket.IO bridge listening on ws://127.0.0.1:${port}`);
|
|
112
|
-
|
|
113
|
-
const close = () => {
|
|
114
|
-
for (const p of pending.values()) {
|
|
115
|
-
clearTimeout(p.timer);
|
|
116
|
-
p.resolve(undefined);
|
|
117
|
-
}
|
|
118
|
-
pending.clear();
|
|
119
|
-
queryables.clear();
|
|
120
|
-
io.close();
|
|
121
|
-
httpServer.close();
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
resolve({ close });
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// When executed directly (inside the bridge Docker container), start the server.
|
|
130
|
-
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
131
|
-
const port = Number(process.env.PORT) || 10000;
|
|
132
|
-
startSocketBridge({ port });
|
|
133
|
-
}
|