Skip to content

Commit 4b4643b

Browse files
committed
[Web runtime] Add SSE subscribe + CORS to HttpTransport
1 parent 04822d0 commit 4b4643b

8 files changed

Lines changed: 675 additions & 16 deletions

File tree

bin/rclnodejs-web.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ const argv = process.argv.slice(2);
9191
port: cfg.http.port,
9292
host: cfg.http.host || cfg.host,
9393
basePath: cfg.http.basePath || cfg.path,
94+
sse: cfg.http.sse,
95+
sseKeepAliveMs:
96+
cfg.http.sseKeepAliveMs != null
97+
? cfg.http.sseKeepAliveMs
98+
: undefined,
99+
cors: cfg.http.cors,
94100
})
95101
);
96102
}
@@ -118,8 +124,11 @@ const argv = process.argv.slice(2);
118124
if (httpTransport) {
119125
const httpHost = displayHost(cfg.http.host || cfg.host);
120126
const httpBase = cfg.http.basePath || cfg.path;
127+
const httpKinds = cfg.http.sse
128+
? 'call/publish + subscribe (SSE)'
129+
: 'call/publish only';
121130
process.stdout.write(
122-
` also http://${httpHost}:${httpTransport.port}${httpBase} (call/publish only)\n`
131+
` also http://${httpHost}:${httpTransport.port}${httpBase} (${httpKinds})\n`
123132
);
124133
}
125134
}

demo/web/javascript/README.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ source /opt/ros/<distro>/setup.bash
1717
node runtime.mjs
1818
# rclnodejs/web : ws://localhost:9000/capability
1919
# also http://localhost:9001/capability (call/publish, curl-able)
20+
# also http://localhost:9001/capability/subscribe/<name> (SSE)
2021
```
2122

2223
`runtime.mjs` exposes a tiny `/add_two_ints` service + 1 Hz
@@ -65,7 +66,60 @@ curl -sS -X POST http://localhost:9001/capability/call/add_two_ints \
6566
# => {"sum":"42n"}
6667
```
6768

68-
Subscribe stays on WebSocket.
69+
This demo's `runtime.mjs` also enables SSE (`new HttpTransport({ sse: true })`),
70+
so `subscribe` is reachable over HTTP as a `text/event-stream` — useful for
71+
clients that can't hold a WebSocket open:
72+
73+
```bash
74+
curl -N http://localhost:9001/capability/subscribe/web_demo_tick
75+
# event: ready
76+
# data: {"capability":"/web_demo_tick","subId":"sse"}
77+
#
78+
# event: message
79+
# data: {"data":"tick 0 @ 2026-06-12T…"}
80+
# …one `message` event per published sample, until you ^C
81+
```
82+
83+
Browser apps should still prefer the WebSocket transport for `subscribe`
84+
(one connection multiplexes every topic). SSE subscribe targets the
85+
curl / AI-agent / server-side persona.
86+
87+
The page also has a **native `EventSource` panel** (section 6) that
88+
subscribes to `/web_demo_tick` over the same SSE endpoint — no SDK, no
89+
WebSocket, just the browser primitive over plain HTTP. Because the page
90+
(`:8080`) and the HTTP transport (`:9001`) are different origins, the
91+
demo's `runtime.mjs` enables CORS (`new HttpTransport({ sse: true, cors:
92+
true })`) so the cross-origin `EventSource` is allowed. In production,
93+
pass your site's origin instead of `true`.
94+
95+
### Pair it with the stock publisher example
96+
97+
The EventSource panel's topic box defaults to `/web_demo_tick`, but the
98+
runtime also exposes `/topic` so you can feed the demo from your own
99+
node instead of the built-in tick loop. In a third shell, run the
100+
standard publisher example:
101+
102+
```bash
103+
source /opt/ros/<distro>/setup.bash
104+
node ../../../example/topics/publisher/publisher-example.mjs
105+
# Publishing message: Hello ROS 0
106+
# Publishing message: Hello ROS 1
107+
#
108+
```
109+
110+
Then set the panel's topic box to `/topic` and click **open
111+
EventSource** — you'll see that node's `Hello ROS N` messages stream in.
112+
The same works over `curl`:
113+
114+
```bash
115+
curl -N http://localhost:9001/capability/subscribe/topic
116+
# event: message
117+
# data: {"data":"Hello ROS 0"}
118+
```
119+
120+
This makes [`publisher-example.mjs`](../../../example/topics/publisher/publisher-example.mjs)
121+
and the web demo a ready-made publisher/subscriber pair for trying the
122+
web runtime against your own publishers.
69123

70124
## Without the bundled `runtime.mjs`
71125

@@ -86,3 +140,8 @@ npx -p rclnodejs rclnodejs-web web.json
86140
ros2 run demo_nodes_cpp add_two_ints_server
87141
# (and a publisher of std_msgs/String on /web_demo_tick from any source)
88142
```
143+
144+
> Note: this demo enables the SSE subscribe endpoint programmatically in
145+
> `runtime.mjs` via `new HttpTransport({ sse: true, cors: true })`. The
146+
> `rclnodejs-web` CLI can do the same with `--http-sse` and `--http-cors`
147+
> (or `"http": { "sse": true, "cors": "*" }` in `web.json`).

demo/web/javascript/index.html

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,12 @@ <h2>5. Same capability, no SDK — just <code>curl</code></h2>
273273
The HTTP transport is what makes <code>rclnodejs/web</code>
274274
<em>actually</em> web-native: every <code>call</code> and
275275
<code>publish</code> in your allow-list is reachable from any HTTP client
276-
— curl, Postman, an AI agent… no JavaScript required. Subscribe stays on
277-
WebSocket.
276+
— curl, Postman, an AI agent… no JavaScript required. With
277+
<code>sse: true</code> on the runtime (set in this demo's
278+
<code>runtime.mjs</code>), <code>subscribe</code> is also reachable as a
279+
Server-Sent Events stream — handy for clients that can't hold a
280+
WebSocket open. Browser apps still prefer the WebSocket transport, which
281+
multiplexes many topics on one connection.
278282
</p>
279283
<pre
280284
class="code"
@@ -289,11 +293,79 @@ <h2>5. Same capability, no SDK — just <code>curl</code></h2>
289293
-H <span class="str">'content-type: application/json'</span> \
290294
-d <span class="str">'{"data":"hi from curl"}'</span>
291295

296+
<span class="com"># subscribe over SSE — streams text/event-stream until you ^C</span>
297+
<span class="com"># (-N disables curl's buffering so events print as they arrive)</span>
298+
curl -N http://localhost:9001/capability/subscribe/web_demo_tick
299+
<span class="com"># event: ready</span>
300+
<span class="com"># data: {"capability":"/web_demo_tick","subId":"sse"}</span>
301+
<span class="com">#</span>
302+
<span class="com"># event: message</span>
303+
<span class="com"># data: {"data":"tick 0 @ 2026-06-12T…"}</span>
304+
<span class="com"># …one `message` event per published sample…</span>
305+
292306
<span class="com"># allow-list rejection — returns 404 + structured error body</span>
293307
curl -sS -X POST http://localhost:9001/capability/call/dangerous \
294308
-H <span class="str">'content-type: application/json'</span> -d <span class="str">'{}'</span>
295309
<span class="com"># =&gt; {"ok":false,"error":"capability not exposed: call /dangerous","code":"not_exposed"}</span></pre>
296310

311+
<h2>
312+
6. Native <code>EventSource</code> — SSE subscribe over HTTP
313+
<span class="badge">no SDK</span>
314+
</h2>
315+
<p style="font-size: 0.9em; color: #555">
316+
The same SSE stream the <code>curl</code> example above reads is a
317+
first-class browser API: <code>EventSource</code>. No SDK, no WebSocket —
318+
just a plain HTTP GET the browser keeps open and auto-reconnects. This
319+
works cross-origin because the runtime sends CORS headers
320+
(<code>new HttpTransport({ sse: true, cors: true })</code>). For
321+
multiplexing many topics on one connection, the WebSocket transport in
322+
section 2 is still the better fit.
323+
</p>
324+
<p style="font-size: 0.9em; color: #555">
325+
The topic box defaults to <code>/web_demo_tick</code> (the demo's
326+
built-in tick loop). It is also wired to <code>/topic</code> so you can
327+
feed the demo from the stock publisher example instead — run
328+
<code
329+
>node ../../../example/topics/publisher/publisher-example.mjs</code
330+
>
331+
in another shell, set the box to <code>/topic</code>, and watch your own
332+
node's messages stream in.
333+
</p>
334+
<div class="panel">
335+
<div class="controls">
336+
<div class="row">
337+
<input
338+
id="sseTopic"
339+
type="text"
340+
value="/web_demo_tick"
341+
spellcheck="false"
342+
aria-label="topic to subscribe to"
343+
/>
344+
<button id="sseBtn">open EventSource</button>
345+
<button id="sseCloseBtn" disabled>close</button>
346+
</div>
347+
<div class="log" id="sseLog"></div>
348+
</div>
349+
<pre
350+
class="code"
351+
><span class="com">// No import — EventSource is built into every browser.</span>
352+
<span class="com">// Swap the topic for '/topic' to pair with publisher-example.mjs.</span>
353+
<span class="kw">const</span> es = <span class="kw">new</span> EventSource(
354+
<span class="str">'http://localhost:9001/capability/subscribe/web_demo_tick'</span>,
355+
);
356+
357+
es.addEventListener(<span class="str">'ready'</span>, (e) =&gt;
358+
console.log(<span class="str">'subscribed:'</span>, JSON.parse(e.data)),
359+
);
360+
es.addEventListener(<span class="str">'message'</span>, (e) =&gt; {
361+
<span class="kw">const</span> msg = JSON.parse(e.data);
362+
console.log(<span class="str">'recv:'</span>, msg.data);
363+
});
364+
365+
<span class="com">// later — stop receiving:</span>
366+
es.close();</pre>
367+
</div>
368+
297369
<script type="module">
298370
import { connect } from '/sdk/index.js';
299371

@@ -444,6 +516,62 @@ <h2>5. Same capability, no SDK — just <code>curl</code></h2>
444516
}
445517
};
446518

519+
// Native EventSource — independent of the WS/HTTP transport
520+
// toggle above. It always talks to the HTTP transport's SSE
521+
// endpoint directly, which is exactly the point: no SDK, no
522+
// WebSocket, just a browser primitive over plain HTTP (CORS is
523+
// enabled on the runtime so this works cross-origin).
524+
const sseBtn = document.getElementById('sseBtn');
525+
const sseCloseBtn = document.getElementById('sseCloseBtn');
526+
const sseTopic = document.getElementById('sseTopic');
527+
let es;
528+
const closeEs = () => {
529+
if (es) {
530+
es.close();
531+
es = null;
532+
}
533+
sseBtn.disabled = false;
534+
sseCloseBtn.disabled = true;
535+
sseTopic.disabled = false;
536+
};
537+
sseBtn.onclick = () => {
538+
closeEs();
539+
// Accept '/topic' or 'topic'; the URL path carries the bare name.
540+
const name = (sseTopic.value || '/web_demo_tick')
541+
.trim()
542+
.replace(/^\//, '');
543+
const url = `${ENDPOINTS.http}/capability/subscribe/${encodeURIComponent(
544+
name
545+
)}`;
546+
es = new EventSource(url);
547+
sseBtn.disabled = true;
548+
sseCloseBtn.disabled = false;
549+
sseTopic.disabled = true;
550+
log('sseLog', `EventSource → ${url}`, 'ok');
551+
es.addEventListener('ready', (e) => {
552+
const info = JSON.parse(e.data);
553+
log('sseLog', `ready (subId=${info.subId})`, 'ok');
554+
});
555+
es.addEventListener('message', (e) => {
556+
log('sseLog', JSON.parse(e.data).data);
557+
});
558+
es.addEventListener('error', (e) => {
559+
// SSE `error` events carry no payload for transport drops;
560+
// our runtime only sends a data-bearing `error` event for a
561+
// terminal failure (e.g. capability removed).
562+
if (e.data) {
563+
const err = JSON.parse(e.data);
564+
log('sseLog', `error: ${err.error} (${err.code})`, 'err');
565+
} else if (es && es.readyState === EventSource.CONNECTING) {
566+
log('sseLog', 'connection lost — auto-reconnecting…', 'err');
567+
}
568+
});
569+
};
570+
sseCloseBtn.onclick = () => {
571+
closeEs();
572+
log('sseLog', 'closed', 'ok');
573+
};
574+
447575
for (const radio of document.querySelectorAll(
448576
'input[name="transport"]'
449577
)) {

demo/web/javascript/runtime.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ const runtime = createRuntime({
101101
new HttpTransport({
102102
port: HTTP_PORT,
103103
host: '::',
104+
// Opt-in Server-Sent Events for `subscribe` over plain HTTP:
105+
// GET /capability/subscribe/<name> (text/event-stream)
106+
// Intended for clients that can't hold a WebSocket open (curl,
107+
// AI agents, serverless / edge functions). Browser apps should
108+
// still prefer the WebSocket transport, which multiplexes many
109+
// topics on one connection. Off by default in HttpTransport.
110+
sse: true,
111+
cors: true,
104112
}),
105113
],
106114
});
@@ -110,6 +118,11 @@ runtime.expose({
110118
subscribe: {
111119
'/web_demo_tick': 'std_msgs/msg/String',
112120
'/web_demo_chatter': 'std_msgs/msg/String',
121+
// Pairs with the stock publisher example so developers can feed the
122+
// demo from their own node instead of the built-in tick loop:
123+
// node ../../../example/topics/publisher/publisher-example.mjs
124+
// then subscribe to `/topic` from the browser / curl / EventSource.
125+
'/topic': 'std_msgs/msg/String',
113126
},
114127
});
115128
await runtime.start();
@@ -127,6 +140,9 @@ console.log(
127140
console.log(
128141
` HTTP : http://${displayHost('::')}:${HTTP_PORT}/capability (call / publish, curl-able)`
129142
);
143+
console.log(
144+
` HTTP SSE : http://${displayHost('::')}:${HTTP_PORT}/capability/subscribe/<name> (subscribe via text/event-stream)`
145+
);
130146
console.log();
131147
console.log(`Exposed capabilities (${total}):`);
132148
console.log(formatCapabilities(caps));

demo/web/typescript/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ npm run server
2929
`server.ts` runs the runtime *and* a tiny `/add_two_ints` service +
3030
1 Hz `/web_demo_tick` publisher so every panel has live data.
3131

32+
> The HTTP transport here serves `call` / `publish` only; `subscribe`
33+
> uses WebSocket. HTTP `subscribe` over Server-Sent Events is an opt-in
34+
> (`new HttpTransport({ sse: true })`, or `--http-sse` on the CLI) — see
35+
> the [JavaScript demo](../javascript/README.md) for a working SSE +
36+
> `EventSource` example.
37+
3238
**Shell 2 — Vite dev server:**
3339

3440
```bash

0 commit comments

Comments
 (0)