@@ -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 >
293307curl -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 "> # => {"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) =>
358+ console.log(< span class ="str "> 'subscribed:'</ span > , JSON.parse(e.data)),
359+ );
360+ es.addEventListener(< span class ="str "> 'message'</ span > , (e) => {
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 ) ) {
0 commit comments