From 4b4643ba7d97ef5f922a069643e10f368d7e8597 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Fri, 12 Jun 2026 14:59:48 +0800 Subject: [PATCH 1/7] [Web runtime] Add SSE subscribe + CORS to HttpTransport --- bin/rclnodejs-web.js | 11 +- demo/web/javascript/README.md | 61 ++++++- demo/web/javascript/index.html | 132 +++++++++++++- demo/web/javascript/runtime.mjs | 16 ++ demo/web/typescript/README.md | 6 + lib/runtime/cli-config.js | 72 +++++++- lib/runtime/transports/http.js | 302 +++++++++++++++++++++++++++++++- test/test-web-cli.js | 91 ++++++++++ 8 files changed, 675 insertions(+), 16 deletions(-) diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index bf519b65..9b9d6789 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 f49446e8..0d98e36f 100644 --- a/demo/web/javascript/README.md +++ b/demo/web/javascript/README.md @@ -17,6 +17,7 @@ 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 @@ -65,7 +66,60 @@ curl -sS -X POST http://localhost:9001/capability/call/add_two_ints \ # => {"sum":"42n"} ``` -Subscribe stays on WebSocket. +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: + +```bash +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, 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 also has a **native `EventSource` panel** (section 6) that +subscribes to `/web_demo_tick` 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`. + +### Pair it with the stock publisher example + +The EventSource panel's topic box defaults to `/web_demo_tick`, but the +runtime also exposes `/topic` so you can feed the demo from your own +node instead of the built-in tick loop. In a third shell, run the +standard publisher example: + +```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`: + +```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` @@ -86,3 +140,8 @@ npx -p rclnodejs rclnodejs-web web.json ros2 run demo_nodes_cpp add_two_ints_server # (and a publisher of std_msgs/String on /web_demo_tick 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`). diff --git a/demo/web/javascript/index.html b/demo/web/javascript/index.html index bf5c2f37..83bae264 100644 --- a/demo/web/javascript/index.html +++ b/demo/web/javascript/index.html @@ -273,8 +273,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_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"}
+

+ 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_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();
+
+ ``` @@ -71,12 +72,12 @@ so `subscribe` is reachable over HTTP as a `text/event-stream` — useful for clients that can't hold a WebSocket open: ```bash -curl -N http://localhost:9001/capability/subscribe/web_demo_tick +curl -N http://localhost:9001/capability/subscribe/web_demo_chatter # event: ready -# data: {"capability":"/web_demo_tick","subId":"sse"} +# data: {"capability":"/web_demo_chatter","subId":"sse"} # # event: message -# data: {"data":"tick 0 @ 2026-06-12T…"} +# data: {"data":"hi from curl"} # …one `message` event per published sample, until you ^C ``` @@ -85,7 +86,7 @@ Browser apps should still prefer the WebSocket transport for `subscribe` curl / AI-agent / server-side persona. The page also has a **native `EventSource` panel** (section 6) that -subscribes to `/web_demo_tick` over the same SSE endpoint — no SDK, no +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: @@ -94,10 +95,10 @@ pass your site's origin instead of `true`. ### Pair it with the stock publisher example -The EventSource panel's topic box defaults to `/web_demo_tick`, but the -runtime also exposes `/topic` so you can feed the demo from your own -node instead of the built-in tick loop. In a third shell, run the -standard publisher example: +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: ```bash source /opt/ros//setup.bash @@ -124,12 +125,11 @@ 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 + 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: +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: ```bash # shell 1 (instead of `node runtime.mjs`); the `-p rclnodejs` tells npx @@ -138,7 +138,7 @@ npx -p rclnodejs rclnodejs-web web.json # the publisher / service 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 a publisher of std_msgs/String on /web_demo_chatter from any source) ``` > Note: this demo enables the SSE subscribe endpoint programmatically in diff --git a/demo/web/javascript/index.html b/demo/web/javascript/index.html index 83bae264..981862d9 100644 --- a/demo/web/javascript/index.html +++ b/demo/web/javascript/index.html @@ -193,7 +193,7 @@

1. Service call — /add_two_ints

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

2. Topic subscription — /web_demo_tick

+

2. Topic subscription — /web_demo_chatter

@@ -208,8 +208,9 @@

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); }); @@ -295,12 +296,12 @@

5. Same capability, no SDK — just 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 +curl -N http://localhost:9001/capability/subscribe/web_demo_chatter # event: ready -# data: {"capability":"/web_demo_tick","subId":"sse"} +# data: {"capability":"/web_demo_chatter","subId":"sse"} # # event: message -# data: {"data":"tick 0 @ 2026-06-12T…"} +# data: {"data":"hi from curl"} # …one `message` event per published sample… # allow-list rejection — returns 404 + structured error body @@ -322,14 +323,11 @@

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. + 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).

@@ -337,7 +335,7 @@

@@ -349,9 +347,9 @@

// 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'); } }