@@ -13,8 +13,8 @@ const debug = require('debug')('rclnodejs:runtime:http');
1313const { Connection } = require ( '../connection.js' ) ;
1414const { TransportAdapter } = require ( '../transport_adapter.js' ) ;
1515
16- // Map dispatcher error codes to HTTP status codes. Anything not listed
17- // here falls back to 500 — see the README for the full table .
16+ // Map dispatcher error codes to HTTP status codes. Codes not listed
17+ // here fall back to 500.
1818const _STATUS_BY_CODE = Object . freeze ( {
1919 not_exposed : 404 ,
2020 not_implemented : 501 ,
@@ -64,9 +64,23 @@ class HttpRequestConnection extends Connection {
6464 this . _payload = payload ;
6565 this . _replied = false ;
6666 this . _closed = false ;
67- // Synthesised frame id so the dispatcher can correlate even though
68- // the HTTP path doesn't carry one on the wire.
67+ // Placeholder frame id. The dispatcher reads `frame.id` from incoming
68+ // frames and echoes it on the reply; over HTTP we discard the reply id
69+ // and write the body directly, so the value just needs to be present.
6970 this . _id = '__http__' ;
71+
72+ // NOTE: we deliberately do *not* wire `req.on('close')` /
73+ // `res.on('close')` to `_emitCloseOnce()` here. Doing so would
74+ // catch client-side disconnects, but the close event also fires
75+ // on normal end-of-response, racing the still-in-flight rcl
76+ // reply callback and tearing down the dispatcher's lazy Client
77+ // mid-flight (rmw throws "client will not receive response" and
78+ // crashes the process). For now, dispatcher cleanup runs from
79+ // `send()` only — a long-running call that the client gave up
80+ // on holds onto its Publisher/Client until `send()` completes.
81+ // The leak is bounded (one entry per capability per request)
82+ // and acceptable until we have a way to drive cleanup that
83+ // doesn't race the rcl reply path.
7084 }
7185
7286 /**
@@ -160,15 +174,16 @@ class HttpRequestConnection extends Connection {
160174}
161175
162176/**
163- * Layer-2 HTTP adapter for the Web Runtime.
177+ * HTTP adapter for the Web Runtime.
164178 *
165179 * Exposes `call` and `publish` capabilities over plain HTTP:
166180 *
167181 * POST /capability/call/<name>
168182 * POST /capability/publish/<name>
169183 *
170- * Subscriptions stay on the WebSocket transport (see WEB_RUNTIME_ROADMAP.md
171- * "Why no SSE?"). Anything that isn't `POST /capability/call/<name>` or
184+ * Subscriptions stay on the WebSocket transport (one HTTP connection
185+ * per inbound message would burn the browser's per-origin budget).
186+ * Anything that isn't `POST /capability/call/<name>` or
172187 * `POST /capability/publish/<name>` returns a 404 / 405 — the adapter
173188 * does not serve unrelated routes.
174189 *
@@ -195,7 +210,7 @@ class HttpTransport extends TransportAdapter {
195210 super ( ) ;
196211 this . port = options . port != null ? options . port : 9001 ;
197212 this . host = options . host != null ? options . host : '::' ;
198- this . basePath = options . basePath || '/capability' ;
213+ this . basePath = _normaliseBasePath ( options . basePath ) ;
199214 this . verifyRequest = options . verifyRequest ;
200215 this . _server = null ;
201216 this . _onConnection = null ;
@@ -230,7 +245,15 @@ class HttpTransport extends TransportAdapter {
230245 if ( ! this . _server ) return ;
231246 const server = this . _server ;
232247 this . _server = null ;
233- return new Promise ( ( resolve ) => server . close ( ( ) => resolve ( ) ) ) ;
248+ return new Promise ( ( resolve ) => {
249+ // Force-close keep-alive sockets so server.close() doesn't hang
250+ // waiting for clients (notably Node's fetch/undici, which keeps
251+ // sockets warm by default). Available since Node 18.2.
252+ if ( typeof server . closeAllConnections === 'function' ) {
253+ server . closeAllConnections ( ) ;
254+ }
255+ server . close ( ( ) => resolve ( ) ) ;
256+ } ) ;
234257 }
235258
236259 // ---------- internals ----------
@@ -276,7 +299,7 @@ class HttpTransport extends TransportAdapter {
276299 } ) ;
277300 }
278301
279- const tail = pathname . slice ( this . basePath . length + 1 ) ; // strip "/capability/"
302+ const tail = pathname . slice ( this . basePath . length + 1 ) ; // strip basePath + '/'
280303 // tail = "<kind>/<rest...>"; first segment is the kind, rest is the
281304 // ROS name (which itself can contain slashes).
282305 const slash = tail . indexOf ( '/' ) ;
@@ -355,21 +378,24 @@ function _readJsonBody(req, maxBytes, cb) {
355378 } ;
356379
357380 const ctype = ( req . headers [ 'content-type' ] || '' ) . toLowerCase ( ) ;
381+ // Only the media-type segment matters; ignore parameters like
382+ // `; charset=utf-8` and reject sneaky values such as
383+ // `text/plain;application/json` that include the right substring
384+ // but mean something else.
385+ const mediaType = ctype . split ( ';' ) [ 0 ] . trim ( ) ;
358386 if (
359387 req . method === 'POST' &&
360- ! ctype . includes ( 'application/json' ) &&
361- ! ctype . startsWith ( 'application/json' )
388+ ctype !== '' &&
389+ mediaType !== 'application/json'
362390 ) {
363391 // Be lenient: empty bodies + missing content-type are treated as
364392 // "publish with no fields", same as `{}`. Only reject explicit
365393 // non-JSON content types.
366- if ( ctype && ctype !== '' ) {
367- const e = new Error (
368- `unsupported content-type: ${ ctype } (expected application/json)`
369- ) ;
370- e . code = 'invalid_content_type' ;
371- return finish ( e ) ;
372- }
394+ const e = new Error (
395+ `unsupported content-type: ${ ctype } (expected application/json)`
396+ ) ;
397+ e . code = 'invalid_content_type' ;
398+ return finish ( e ) ;
373399 }
374400 let total = 0 ;
375401 const chunks = [ ] ;
@@ -403,4 +429,22 @@ function _readJsonBody(req, maxBytes, cb) {
403429 } ) ;
404430}
405431
432+ function _normaliseBasePath ( value ) {
433+ if ( value === undefined || value === null || value === '' ) {
434+ return '/capability' ;
435+ }
436+ if ( typeof value !== 'string' ) {
437+ throw new TypeError (
438+ `HttpTransport: basePath must be a string, got ${ typeof value } `
439+ ) ;
440+ }
441+ // Ensure single leading slash, no trailing slash.
442+ let p = value . replace ( / \/ + $ / , '' ) ;
443+ if ( ! p . startsWith ( '/' ) ) p = '/' + p ;
444+ if ( p === '' ) {
445+ throw new TypeError ( 'HttpTransport: basePath must not be empty' ) ;
446+ }
447+ return p ;
448+ }
449+
406450module . exports = { HttpTransport, HttpRequestConnection } ;
0 commit comments