Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion bin/rclnodejs-web.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ const argv = process.argv.slice(2);
port: cfg.http.port,
host: cfg.http.host || cfg.host,
basePath: cfg.http.basePath || cfg.path,
sse: cfg.http.sse,
sseKeepAliveMs:
cfg.http.sseKeepAliveMs != null
? cfg.http.sseKeepAliveMs
: undefined,
cors: cfg.http.cors,
})
);
}
Expand Down Expand Up @@ -118,8 +124,11 @@ const argv = process.argv.slice(2);
if (httpTransport) {
const httpHost = displayHost(cfg.http.host || cfg.host);
const httpBase = cfg.http.basePath || cfg.path;
const httpKinds = cfg.http.sse
? 'call/publish + subscribe (SSE)'
: 'call/publish only';
process.stdout.write(
` also http://${httpHost}:${httpTransport.port}${httpBase} (call/publish only)\n`
` also http://${httpHost}:${httpTransport.port}${httpBase} (${httpKinds})\n`
);
}
}
Expand Down
76 changes: 57 additions & 19 deletions demo/web/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ source /opt/ros/<distro>/setup.bash
node runtime.mjs
# rclnodejs/web : ws://localhost:9000/capability
# also http://localhost:9001/capability (call/publish, curl-able)
# also http://localhost:9001/capability/subscribe/<name> (SSE)
```

`runtime.mjs` exposes a tiny `/add_two_ints` service + 1 Hz
`/web_demo_tick` publisher so every panel has live data.
`runtime.mjs` exposes a tiny `/add_two_ints` service and the shared
`/web_demo_chatter` talker/listener topic (publish from one panel,
receive in the others).

**Shell 2 — static-file server (hosts `index.html` + maps `/sdk/*` to
the in-repo [`web/`](../../../web/) folder so the page can `import`
Expand All @@ -45,7 +47,7 @@ Open <http://localhost:8080/> in any modern browser. Runtime in shell
const reply = await ros.call('/add_two_ints', { a: '2n', b: '40n' });
console.log(reply.sum); // '42n'

await ros.subscribe('/web_demo_tick', (msg) => render(msg.data));
await ros.subscribe('/web_demo_chatter', (msg) => render(msg.data));
await ros.publish('/web_demo_chatter', { data: 'hi' });
</script>
```
Expand All @@ -55,34 +57,70 @@ can flip the SDK between the two without restarting.

## Same capability, no SDK

Every `call` / `publish` is also reachable as plain HTTP — drive the
runtime from `curl`, Postman, or an AI agent without any JavaScript:
Every `call` / `publish` is reachable as plain HTTP — drive the runtime
from `curl`, Postman, or an AI agent, no JavaScript required:

```bash
curl -sS -X POST http://localhost:9001/capability/call/add_two_ints \
-H 'content-type: application/json' \
-d '{"a":"7n","b":"35n"}'
-H 'content-type: application/json' -d '{"a":"7n","b":"35n"}'
# => {"sum":"42n"}
```

Subscribe stays on WebSocket.
The demo also enables SSE (`new HttpTransport({ sse: true })`), so
`subscribe` works over HTTP as a `text/event-stream` — handy for clients
that can't hold a WebSocket open:

```bash
curl -N http://localhost:9001/capability/subscribe/web_demo_chatter
# event: ready
# data: {"capability":"/web_demo_chatter","subId":"sse"}
#
# event: message
# data: {"data":"hi from curl"}
```

The page's **native `EventSource` panel** (section 6) reads this same
stream — no SDK, no WebSocket. It works cross-origin (`:8080` → `:9001`)
because the demo also enables CORS (`new HttpTransport({ sse: true, cors:
true })`); in production, pass your site's origin instead of `true`.

> For browser apps, prefer the WebSocket transport for `subscribe` — one
> connection multiplexes every topic. SSE targets the curl / AI-agent /
> server-side persona.

### Pair it with your own publisher

The runtime also exposes `/topic`, so you can feed the demo from any ROS 2
node instead of the in-page publisher. Run the stock publisher example in
a third shell, then point the EventSource panel (or `curl`) at `/topic`:

```bash
source /opt/ros/<distro>/setup.bash
node ../../../example/topics/publisher/publisher-example.mjs
# Publishing message: Hello ROS 0, 1, 2, …

curl -N http://localhost:9001/capability/subscribe/topic
# event: message
# data: {"data":"Hello ROS 0"}
```

## Without the bundled `runtime.mjs`

`runtime.mjs` bundles the rclnodejs/web runtime and the demo's sample
ROS 2 nodes (the `/add_two_ints` service + the `/web_demo_tick`
publisher) into one process so the demo runs out of the box. In a
real project you already have those ROS 2 nodes running elsewhere,
so you only need the runtime. **Replace shell 1's `node runtime.mjs`
with the CLI** — shell 2 (`node static.mjs`) and the browser code are
unchanged:
`runtime.mjs` bundles the runtime and the demo's sample nodes into one
process so it runs out of the box. In a real project those nodes already
run elsewhere, so you only need the runtime — replace shell 1 with the
CLI (shell 2 and the browser code are unchanged):

```bash
# shell 1 (instead of `node runtime.mjs`); the `-p rclnodejs` tells npx
# the `rclnodejs-web` binary lives inside the `rclnodejs` package:
# the `-p rclnodejs` tells npx the binary lives in the rclnodejs package:
npx -p rclnodejs rclnodejs-web web.json

# the publisher / service the demo expects:
# plus the service the demo expects (and any std_msgs/String publisher
# on /web_demo_chatter):
ros2 run demo_nodes_cpp add_two_ints_server
# (and a publisher of std_msgs/String on /web_demo_tick from any source)
```

> The bundled `runtime.mjs` enables SSE + CORS via
> `new HttpTransport({ sse: true, cors: true })`. The CLI does the same
> with `--http-sse` / `--http-cors` (or `"http": { "sse": true, "cors":
> "*" }` in `web.json`).
138 changes: 132 additions & 6 deletions demo/web/javascript/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ <h2>1. Service call — <code>/add_two_ints</code></h2>
console.log(reply.sum); <span class="com">// '42n'</span></pre>
</div>

<h2>2. Topic subscription — <code>/web_demo_tick</code></h2>
<h2>2. Topic subscription — <code>/web_demo_chatter</code></h2>
<div class="panel">
<div class="controls">
<div class="row">
Expand All @@ -208,8 +208,9 @@ <h2>2. Topic subscription — <code>/web_demo_tick</code></h2>

<span class="kw">const</span> ros = <span class="kw">await</span> connect(<span class="str">'ws://localhost:9000/capability'</span>);

<span class="com">// The server publishes /web_demo_tick once a second.</span>
<span class="kw">const</span> sub = <span class="kw">await</span> ros.<span class="kw">subscribe</span>(<span class="str">'/web_demo_tick'</span>, (msg) =&gt; {
<span class="com">// /web_demo_chatter is the shared demo topic — anything panel 3</span>
<span class="com">// (or curl) publishes to it lands here, since it's the same topic.</span>
<span class="kw">const</span> sub = <span class="kw">await</span> ros.<span class="kw">subscribe</span>(<span class="str">'/web_demo_chatter'</span>, (msg) =&gt; {
console.log(<span class="str">'recv:'</span>, msg.data);
});

Expand Down Expand Up @@ -273,8 +274,12 @@ <h2>5. Same capability, no SDK — just <code>curl</code></h2>
The HTTP transport is what makes <code>rclnodejs/web</code>
<em>actually</em> web-native: every <code>call</code> and
<code>publish</code> in your allow-list is reachable from any HTTP client
— curl, Postman, an AI agent… no JavaScript required. Subscribe stays on
WebSocket.
— curl, Postman, an AI agent… no JavaScript required. With
<code>sse: true</code> on the runtime (set in this demo's
<code>runtime.mjs</code>), <code>subscribe</code> is also reachable as a
Server-Sent Events stream — handy for clients that can't hold a
WebSocket open. Browser apps still prefer the WebSocket transport, which
multiplexes many topics on one connection.
</p>
<pre
class="code"
Expand All @@ -289,11 +294,76 @@ <h2>5. Same capability, no SDK — just <code>curl</code></h2>
-H <span class="str">'content-type: application/json'</span> \
-d <span class="str">'{"data":"hi from curl"}'</span>

<span class="com"># subscribe over SSE — streams text/event-stream until you ^C</span>
<span class="com"># (-N disables curl's buffering so events print as they arrive)</span>
curl -N http://localhost:9001/capability/subscribe/web_demo_chatter
<span class="com"># event: ready</span>
<span class="com"># data: {"capability":"/web_demo_chatter","subId":"sse"}</span>
<span class="com">#</span>
<span class="com"># event: message</span>
<span class="com"># data: {"data":"hi from curl"}</span>
<span class="com"># …one `message` event per published sample…</span>

<span class="com"># allow-list rejection — returns 404 + structured error body</span>
curl -sS -X POST http://localhost:9001/capability/call/dangerous \
-H <span class="str">'content-type: application/json'</span> -d <span class="str">'{}'</span>
<span class="com"># =&gt; {"ok":false,"error":"capability not exposed: call /dangerous","code":"not_exposed"}</span></pre>

<h2>
6. Native <code>EventSource</code> — SSE subscribe over HTTP
<span class="badge">no SDK</span>
</h2>
<p style="font-size: 0.9em; color: #555">
The same SSE stream the <code>curl</code> example above reads is a
first-class browser API: <code>EventSource</code>. No SDK, no WebSocket —
just a plain HTTP GET the browser keeps open and auto-reconnects. This
works cross-origin because the runtime sends CORS headers
(<code>new HttpTransport({ sse: true, cors: true })</code>). For
multiplexing many topics on one connection, the WebSocket transport in
section 2 is still the better fit.
</p>
<p style="font-size: 0.9em; color: #555">
The topic box defaults to <code>/web_demo_chatter</code> — the same
topic panels 2 and 3 use, so a publish from panel 3 streams in here at
the same time it reaches the WebSocket subscriber. Point the box at
<code>/topic</code> instead to subscribe to an external ROS 2 publisher
(see the README's <em>publisher example</em> pairing).
</p>
<div class="panel">
<div class="controls">
<div class="row">
<input
id="sseTopic"
type="text"
value="/web_demo_chatter"
spellcheck="false"
aria-label="topic to subscribe to"
/>
<button id="sseBtn">open EventSource</button>
<button id="sseCloseBtn" disabled>close</button>
</div>
<div class="log" id="sseLog"></div>
</div>
<pre
class="code"
><span class="com">// No import — EventSource is built into every browser.</span>
<span class="com">// '/web_demo_chatter' is the shared demo topic; use '/topic' to pair with publisher-example.mjs.</span>
<span class="kw">const</span> es = <span class="kw">new</span> EventSource(
<span class="str">'http://localhost:9001/capability/subscribe/web_demo_chatter'</span>,
);

es.addEventListener(<span class="str">'ready'</span>, (e) =&gt;
console.log(<span class="str">'subscribed:'</span>, JSON.parse(e.data)),
);
es.addEventListener(<span class="str">'message'</span>, (e) =&gt; {
<span class="kw">const</span> msg = JSON.parse(e.data);
console.log(<span class="str">'recv:'</span>, msg.data);
});

<span class="com">// later — stop receiving:</span>
es.close();</pre>
</div>

<script type="module">
import { connect } from '/sdk/index.js';

Expand Down Expand Up @@ -400,7 +470,7 @@ <h2>5. Same capability, no SDK — just <code>curl</code></h2>
subBtn.onclick = async () => {
if (!ros) return;
try {
tickSub = await ros.subscribe('/web_demo_tick', (msg) =>
tickSub = await ros.subscribe('/web_demo_chatter', (msg) =>
log('tickLog', msg.data)
);
subBtn.disabled = true;
Expand Down Expand Up @@ -444,6 +514,62 @@ <h2>5. Same capability, no SDK — just <code>curl</code></h2>
}
};

// Native EventSource — independent of the WS/HTTP transport
// toggle above. It always talks to the HTTP transport's SSE
// endpoint directly, which is exactly the point: no SDK, no
// WebSocket, just a browser primitive over plain HTTP (CORS is
// enabled on the runtime so this works cross-origin).
const sseBtn = document.getElementById('sseBtn');
const sseCloseBtn = document.getElementById('sseCloseBtn');
const sseTopic = document.getElementById('sseTopic');
let es;
const closeEs = () => {
if (es) {
es.close();
es = null;
}
sseBtn.disabled = false;
sseCloseBtn.disabled = true;
sseTopic.disabled = false;
};
sseBtn.onclick = () => {
closeEs();
// Accept '/topic' or 'topic'; the URL path carries the bare name.
const name = (sseTopic.value || '/web_demo_chatter')
.trim()
.replace(/^\//, '');
const url = `${ENDPOINTS.http}/capability/subscribe/${encodeURIComponent(
name
)}`;
es = new EventSource(url);
sseBtn.disabled = true;
sseCloseBtn.disabled = false;
sseTopic.disabled = true;
log('sseLog', `EventSource → ${url}`, 'ok');
es.addEventListener('ready', (e) => {
const info = JSON.parse(e.data);
log('sseLog', `ready (subId=${info.subId})`, 'ok');
});
es.addEventListener('message', (e) => {
log('sseLog', JSON.parse(e.data).data);
});
es.addEventListener('error', (e) => {
// SSE `error` events carry no payload for transport drops;
// our runtime only sends a data-bearing `error` event for a
// terminal failure (e.g. capability removed).
if (e.data) {
const err = JSON.parse(e.data);
log('sseLog', `error: ${err.error} (${err.code})`, 'err');
} else if (es && es.readyState === EventSource.CONNECTING) {
log('sseLog', 'connection lost — auto-reconnecting…', 'err');
}
});
};
sseCloseBtn.onclick = () => {
closeEs();
log('sseLog', 'closed', 'ok');
};

for (const radio of document.querySelectorAll(
'input[name="transport"]'
)) {
Expand Down
33 changes: 20 additions & 13 deletions demo/web/javascript/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function displayHost(host) {
// Render the registry as a small human-readable table:
// call /add_two_ints example_interfaces/srv/AddTwoInts
// publish /web_demo_chatter std_msgs/msg/String
// subscribe /web_demo_tick std_msgs/msg/String
// subscribe /web_demo_chatter std_msgs/msg/String
function formatCapabilities(caps) {
const rows = [];
for (const verb of ['call', 'publish', 'subscribe']) {
Expand Down Expand Up @@ -69,17 +69,6 @@ node.createService(
}
);

// A real ROS 2 publisher producing a tick once a second so the
// browser's subscribe() has something to receive without the user
// having to publish first.
const tickPub = node.createPublisher('std_msgs/msg/String', '/web_demo_tick');
let counter = 0;
setInterval(() => {
tickPub.publish({
data: `tick ${counter++} @ ${new Date().toISOString()}`,
});
}, 1000);

rclnodejs.spin(node);

// ---- Layer 2 + 3: capability runtime over WebSocket *and* HTTP -------
Expand All @@ -101,15 +90,30 @@ const runtime = createRuntime({
new HttpTransport({
port: HTTP_PORT,
host: '::',
// Opt-in Server-Sent Events for `subscribe` over plain HTTP:
// GET /capability/subscribe/<name> (text/event-stream)
// Intended for clients that can't hold a WebSocket open (curl,
// AI agents, serverless / edge functions). Browser apps should
// still prefer the WebSocket transport, which multiplexes many
// topics on one connection. Off by default in HttpTransport.
sse: true,
cors: true,
}),
],
});
runtime.expose({
call: { '/add_two_ints': 'example_interfaces/srv/AddTwoInts' },
publish: { '/web_demo_chatter': 'std_msgs/msg/String' },
subscribe: {
'/web_demo_tick': 'std_msgs/msg/String',
// Shared talker/listener topic: panels 2 (WebSocket), 3 (round-trip),
// and 6 (SSE) all use it — so a browser publish is visible across
// every subscriber at once.
'/web_demo_chatter': 'std_msgs/msg/String',
// Pairs with the stock publisher example so developers can feed the
// demo from their own node:
// node ../../../example/topics/publisher/publisher-example.mjs
// then subscribe to `/topic` from the browser / curl / EventSource.
'/topic': 'std_msgs/msg/String',
},
});
await runtime.start();
Expand All @@ -127,6 +131,9 @@ console.log(
console.log(
` HTTP : http://${displayHost('::')}:${HTTP_PORT}/capability (call / publish, curl-able)`
);
console.log(
` HTTP SSE : http://${displayHost('::')}:${HTTP_PORT}/capability/subscribe/<name> (subscribe via text/event-stream)`
);
console.log();
console.log(`Exposed capabilities (${total}):`);
console.log(formatCapabilities(caps));
Expand Down
Loading
Loading