From 4b4643ba7d97ef5f922a069643e10f368d7e8597 Mon Sep 17 00:00:00 2001
From: Minggang Wang 5. Same capability, no SDK — just
The HTTP transport is what makes curlrclnodejs/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_tick
+# event: ready
+# data: {"capability":"/web_demo_tick","subId":"sse"}
+#
+# event: message
+# data: {"data":"tick 0 @ 2026-06-12T…"}
+# …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"}
+ 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_tick (the demo's
+ built-in tick loop). It is also wired to /topic so you can
+ feed the demo from the stock publisher example instead — run
+ node ../../../example/topics/publisher/publisher-example.mjs
+ in another shell, set the box to /topic, and watch your own
+ node's messages stream in.
+
// No import — EventSource is built into every browser. +// Swap the topic for '/topic' to pair with publisher-example.mjs. +const es = new EventSource( + 'http://localhost:9001/capability/subscribe/web_demo_tick', +); + +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();+
/add_two_ints/web_demo_tick/web_demo_chatter/web_demo_tickcurl
- The topic box defaults to /web_demo_tick (the demo's
- built-in tick loop). It is also wired to /topic so you can
- feed the demo from the stock publisher example instead — run
- node ../../../example/topics/publisher/publisher-example.mjs
- in another shell, set the box to /topic, and watch your own
- node's messages stream in.
+ 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. -// Swap the topic for '/topic' to pair with publisher-example.mjs. +// '/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_tick', + 'http://localhost:9001/capability/subscribe/web_demo_chatter', ); es.addEventListener('ready', (e) => @@ -472,7 +470,7 @@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; @@ -537,7 +535,7 @@
sseBtn.onclick = () => { closeEs(); // Accept '/topic' or 'topic'; the URL path carries the bare name. - const name = (sseTopic.value || '/web_demo_tick') + const name = (sseTopic.value || '/web_demo_chatter') .trim() .replace(/^\//, ''); const url = `${ENDPOINTS.http}/capability/subscribe/${encodeURIComponent( diff --git a/demo/web/javascript/runtime.mjs b/demo/web/javascript/runtime.mjs index 943eb85e..8616f82f 100644 --- a/demo/web/javascript/runtime.mjs +++ b/demo/web/javascript/runtime.mjs @@ -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']) { @@ -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 ------- @@ -116,10 +105,12 @@ 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 instead of the built-in tick loop: + // 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', diff --git a/demo/web/javascript/web.json b/demo/web/javascript/web.json index 561f65fc..ff4f0b59 100644 --- a/demo/web/javascript/web.json +++ b/demo/web/javascript/web.json @@ -15,8 +15,8 @@ "/web_demo_chatter": "std_msgs/msg/String" }, "subscribe": { - "/web_demo_tick": "std_msgs/msg/String", - "/web_demo_chatter": "std_msgs/msg/String" + "/web_demo_chatter": "std_msgs/msg/String", + "/topic": "std_msgs/msg/String" } } } From 18eafdae741f1aa0c4ae41cb1847004522806889 Mon Sep 17 00:00:00 2001 From: Minggang Wang
Date: Fri, 12 Jun 2026 17:45:18 +0800 Subject: [PATCH 4/7] Address comments --- demo/web/javascript/README.md | 79 +++++++++++++---------------------- demo/web/typescript/README.md | 24 ++++------- 2 files changed, 38 insertions(+), 65 deletions(-) diff --git a/demo/web/javascript/README.md b/demo/web/javascript/README.md index 77c33646..2a33997a 100644 --- a/demo/web/javascript/README.md +++ b/demo/web/javascript/README.md @@ -57,19 +57,18 @@ 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"} ``` -This demo's `runtime.mjs` also enables SSE (`new HttpTransport({ sse: true })`), -so `subscribe` is reachable over HTTP as a `text/event-stream` — useful for -clients that can't hold a WebSocket open: +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 @@ -78,70 +77,50 @@ curl -N http://localhost:9001/capability/subscribe/web_demo_chatter # # event: message # data: {"data":"hi from curl"} -# …one `message` event per published sample, until you ^C ``` -Browser apps should still prefer the WebSocket transport for `subscribe` -(one connection multiplexes every topic). SSE subscribe targets the -curl / AI-agent / server-side persona. +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`. -The page also has a **native `EventSource` panel** (section 6) that -subscribes to `/web_demo_chatter` over the same SSE endpoint — no SDK, no -WebSocket, just the browser primitive over plain HTTP. Because the page -(`:8080`) and the HTTP transport (`:9001`) are different origins, the -demo's `runtime.mjs` enables CORS (`new HttpTransport({ sse: true, cors: -true })`) so the cross-origin `EventSource` is allowed. 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 the stock publisher example +### Pair it with your own publisher -The EventSource panel's topic box defaults to `/web_demo_chatter` (the -shared demo topic), but the runtime also exposes `/topic` so you can -feed the demo from your own node. In a third shell, run the standard -publisher example: +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 -# Publishing message: Hello ROS 1 -# … -``` - -Then set the panel's topic box to `/topic` and click **open -EventSource** — you'll see that node's `Hello ROS N` messages stream in. -The same works over `curl`: +# Publishing message: Hello ROS 0, 1, 2, … -```bash curl -N http://localhost:9001/capability/subscribe/topic # event: message # data: {"data":"Hello ROS 0"} ``` -This makes [`publisher-example.mjs`](../../../example/topics/publisher/publisher-example.mjs) -and the web demo a ready-made publisher/subscriber pair for trying the -web runtime against your own publishers. - ## 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) 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_chatter from any source) ``` -> Note: this demo enables the SSE subscribe endpoint programmatically in -> `runtime.mjs` via `new HttpTransport({ sse: true, cors: true })`. The -> `rclnodejs-web` CLI can do the same with `--http-sse` and `--http-cors` -> (or `"http": { "sse": true, "cors": "*" }` in `web.json`). +> 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/typescript/README.md b/demo/web/typescript/README.md index 7f714cd9..696d0fb2 100644 --- a/demo/web/typescript/README.md +++ b/demo/web/typescript/README.md @@ -26,8 +26,8 @@ npm run server # also http://localhost:9001/capability ``` -`server.ts` runs the runtime *and* a tiny `/add_two_ints` service + -1 Hz `/web_demo_tick` publisher so every panel has live data. +`server.ts` runs the runtime *plus* a tiny `/add_two_ints` service and a +1 Hz `/web_demo_tick` publisher, so every panel has live data. > The HTTP transport here serves `call` / `publish` only; `subscribe` > uses WebSocket. HTTP `subscribe` over Server-Sent Events is an opt-in @@ -44,26 +44,20 @@ npm run dev ## Without the bundled `server.ts` -`npm run server` is a convenience for this demo — it bundles the -runtime **and** a tiny `/add_two_ints` service + `/web_demo_tick` -publisher into one process so the demo works 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 -`npm run server` with the CLI** — shell 2 (`npm run dev`) and -`src/main.ts` are unchanged: +`npm run server` 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 `src/main.ts` are unchanged): ```bash -# shell 1 (instead of `npm run server`) npx rclnodejs-web web.json -# the publisher / service the demo expects: +# plus the nodes the demo expects: ros2 run demo_nodes_cpp add_two_ints_server -# (and a publisher of std_msgs/String on /web_demo_tick from any source) +# (and any std_msgs/String publisher on /web_demo_tick) ``` -The browser doesn't know or care which option is running — it only -sees `ws://localhost:9000/capability` either way. +The browser only sees `ws://localhost:9000/capability` either way. ## Other npm scripts From adc39185340793ffaa8418ab67a3d14adf1c5ac1 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Mon, 15 Jun 2026 16:41:25 +0800 Subject: [PATCH 5/7] Address comments --- demo/web/javascript/index.html | 26 +++++++++++++------------- demo/web/javascript/web.json | 6 ++++-- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/demo/web/javascript/index.html b/demo/web/javascript/index.html index 981862d9..ea361037 100644 --- a/demo/web/javascript/index.html +++ b/demo/web/javascript/index.html @@ -200,7 +200,7 @@ 2. Topic subscription —
/web_demo_chatter
};
let ros;
- let tickSub;
+ let chatterSub;
async function teardown() {
- if (tickSub) {
+ if (chatterSub) {
try {
- await tickSub.close();
+ await chatterSub.close();
} catch (_) {}
- tickSub = null;
+ chatterSub = null;
document.getElementById('subBtn').disabled = false;
document.getElementById('unsubBtn').disabled = true;
}
@@ -470,23 +470,23 @@
subBtn.onclick = async () => {
if (!ros) return;
try {
- tickSub = await ros.subscribe('/web_demo_chatter', (msg) =>
- log('tickLog', msg.data)
+ chatterSub = await ros.subscribe('/web_demo_chatter', (msg) =>
+ log('subLog', msg.data)
);
subBtn.disabled = true;
unsubBtn.disabled = false;
- log('tickLog', `subscribed (subId=${tickSub.subId})`, 'ok');
+ log('subLog', `subscribed (subId=${chatterSub.subId})`, 'ok');
} catch (e) {
- log('tickLog', `error: ${e.message} (${e.code})`, 'err');
+ log('subLog', `error: ${e.message} (${e.code})`, 'err');
}
};
unsubBtn.onclick = async () => {
- if (!tickSub) return;
- await tickSub.close();
- tickSub = null;
+ if (!chatterSub) return;
+ await chatterSub.close();
+ chatterSub = null;
unsubBtn.disabled = true;
subBtn.disabled = false;
- log('tickLog', 'unsubscribed', 'ok');
+ log('subLog', 'unsubscribed', 'ok');
};
document.getElementById('pubBtn').onclick = async () => {
diff --git a/demo/web/javascript/web.json b/demo/web/javascript/web.json
index ff4f0b59..61a86daa 100644
--- a/demo/web/javascript/web.json
+++ b/demo/web/javascript/web.json
@@ -4,8 +4,10 @@
"path": "/capability",
"node": "rclnodejs_web_demo",
"http": {
- "$comment": "HTTP transport for `call` and `publish`. curl-able, AI-agent friendly. Subscribe still uses WebSocket.",
- "port": 9001
+ "$comment": "HTTP transport for `call` and `publish` (curl-able, AI-agent friendly). `sse` also exposes `subscribe` over Server-Sent Events; `cors: \"*\"` allows any origin so a cross-origin browser EventSource can connect. Matches the bundled runtime.mjs.",
+ "port": 9001,
+ "sse": true,
+ "cors": "*"
},
"expose": {
"call": {
From 8c4a641507ffdecb0544823028763ae29c5e482e Mon Sep 17 00:00:00 2001
From: Minggang Wang
Date: Mon, 15 Jun 2026 17:09:00 +0800
Subject: [PATCH 6/7] Fix build error with vs2026
---
.../ref-napi/src/ref_napi_bindings.cpp | 25 ++++++++++++-------
1 file changed, 16 insertions(+), 9 deletions(-)
diff --git a/third_party/ref-napi/src/ref_napi_bindings.cpp b/third_party/ref-napi/src/ref_napi_bindings.cpp
index 63954f33..bc4af20a 100644
--- a/third_party/ref-napi/src/ref_napi_bindings.cpp
+++ b/third_party/ref-napi/src/ref_napi_bindings.cpp
@@ -100,15 +100,22 @@ class PointerBuffer : public ObjectWrap {
};
Object PointerBuffer::Init(Napi::Env env, Object exports) {
- Function func =
- DefineClass(env, "PointerBuffer",
- {InstanceMethod("isNull", &PointerBuffer::IsNull),
- InstanceMethod("get", &PointerBuffer::Get),
- InstanceMethod("address", &PointerBuffer::Address),
- InstanceMethod("toString", &PointerBuffer::ToString),
- InstanceMethod("copy", &PointerBuffer::Copy),
- InstanceMethod("slice", &PointerBuffer::Slice),
- InstanceAccessor<&PointerBuffer::Length>("length")});
+ Function func = DefineClass(
+ env, "PointerBuffer",
+ {InstanceMethod("isNull", &PointerBuffer::IsNull),
+ InstanceMethod("get", &PointerBuffer::Get),
+ InstanceMethod("address", &PointerBuffer::Address),
+ InstanceMethod("toString", &PointerBuffer::ToString),
+ InstanceMethod("copy", &PointerBuffer::Copy),
+ InstanceMethod("slice", &PointerBuffer::Slice),
+ // Use the runtime InstanceAccessor(name, getter, setter)
+ // overload rather than the templated
+ // InstanceAccessor<&Fn>() form: the latter's constexpr
+ // member-pointer template argument triggers an MSVC
+ // Internal Compiler Error (C1001) on the VS 2026 (v18)
+ // toolset used by the windows-2025 CI runner. This form
+ // is equivalent and compiles cleanly on every platform.
+ InstanceAccessor("length", &PointerBuffer::Length, nullptr)});
exports.Set("PointerBuffer", func);
InstanceData* data = InstanceData::Get(env);
From ecc39b16cbc48dfc31a490bcff76e255f24b91ea Mon Sep 17 00:00:00 2001
From: Minggang Wang
Date: Mon, 15 Jun 2026 17:40:40 +0800
Subject: [PATCH 7/7] Address comments
---
lib/runtime/transports/http.js | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/lib/runtime/transports/http.js b/lib/runtime/transports/http.js
index 561d0f32..4ef7c63d 100644
--- a/lib/runtime/transports/http.js
+++ b/lib/runtime/transports/http.js
@@ -606,6 +606,12 @@ class HttpTransport extends TransportAdapter {
* `cors` is disabled. For an allow-list, the request `Origin` is echoed
* only when it matches (and `Vary: Origin` is set so caches don't mix
* responses across origins).
+ *
+ * `Access-Control-Allow-Headers` reflects the browser's
+ * `Access-Control-Request-Headers` when present, so authenticated or
+ * custom-header requests (`Authorization`, `X-*`, …) pass preflight
+ * rather than being limited to `content-type`. The reflected value is
+ * added to `Vary` to keep caches correct.
*/
_applyCors(req, res) {
if (!this.cors) return;
@@ -620,7 +626,15 @@ class HttpTransport extends TransportAdapter {
}
}
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
- res.setHeader('access-control-allow-headers', 'content-type');
+ // Echo whatever headers the browser says it will send; fall back to
+ // `content-type` for non-preflight requests that carry no such hint.
+ const requested = req.headers['access-control-request-headers'];
+ if (requested) {
+ res.appendHeader('vary', 'Access-Control-Request-Headers');
+ res.setHeader('access-control-allow-headers', requested);
+ } else {
+ res.setHeader('access-control-allow-headers', 'content-type');
+ }
res.setHeader('access-control-max-age', '86400');
}
}