diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index bf519b65c..9b9d67890 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -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, }) ); } @@ -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` ); } } diff --git a/demo/web/javascript/README.md b/demo/web/javascript/README.md index f49446e84..2a33997af 100644 --- a/demo/web/javascript/README.md +++ b/demo/web/javascript/README.md @@ -17,10 +17,12 @@ source /opt/ros//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/ (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` @@ -45,7 +47,7 @@ Open 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' }); ``` @@ -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//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`). diff --git a/demo/web/javascript/index.html b/demo/web/javascript/index.html index bf5c2f372..ea3610374 100644 --- a/demo/web/javascript/index.html +++ b/demo/web/javascript/index.html @@ -193,14 +193,14 @@

1. Service call — /add_two_ints

console.log(reply.sum); // '42n' -

2. Topic subscription — /web_demo_tick

+

2. Topic subscription — /web_demo_chatter

-
+
2. Topic subscription — /web_demo_tick
 
 const ros = await connect('ws://localhost:9000/capability');
 
-// The server publishes /web_demo_tick once a second.
-const sub = await ros.subscribe('/web_demo_tick', (msg) => {
+// /web_demo_chatter is the shared demo topic — anything panel 3
+// (or curl) publishes to it lands here, since it's the same topic.
+const sub = await ros.subscribe('/web_demo_chatter', (msg) => {
   console.log('recv:', msg.data);
 });
 
@@ -273,8 +274,12 @@ 

5. Same capability, no SDK — just curl

The HTTP transport is what makes rclnodejs/web actually web-native: every call and publish 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 + sse: true on the runtime (set in this demo's + runtime.mjs), subscribe 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.

5. Same capability, no SDK — just curl
   -H 'content-type: application/json' \
   -d '{"data":"hi from curl"}'
 
+# subscribe over SSE — streams text/event-stream until you ^C
+# (-N disables curl's buffering so events print as they arrive)
+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"}
+# …one `message` event per published sample…
+
 # allow-list rejection — returns 404 + structured error body
 curl -sS -X POST http://localhost:9001/capability/call/dangerous \
   -H 'content-type: application/json' -d '{}'
 # => {"ok":false,"error":"capability not exposed: call /dangerous","code":"not_exposed"}
+

+ 6. Native EventSource — SSE subscribe over HTTP + no SDK +

+

+ The same SSE stream the curl example above reads is a + first-class browser API: EventSource. 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 + (new HttpTransport({ sse: true, cors: true })). For + multiplexing many topics on one connection, the WebSocket transport in + section 2 is still the better fit. +

+

+ The topic box defaults to /web_demo_chatter — 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 + /topic instead to subscribe to an external ROS 2 publisher + (see the README's publisher example pairing). +

+
+
+
+ + + +
+
+
+
// No import — EventSource is built into every browser.
+// '/web_demo_chatter' is the shared demo topic; use '/topic' to pair with publisher-example.mjs.
+const es = new EventSource(
+  'http://localhost:9001/capability/subscribe/web_demo_chatter',
+);
+
+es.addEventListener('ready', (e) =>
+  console.log('subscribed:', JSON.parse(e.data)),
+);
+es.addEventListener('message', (e) => {
+  const msg = JSON.parse(e.data);
+  console.log('recv:', msg.data);
+});
+
+// later — stop receiving:
+es.close();
+
+