• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

RobotWebTools / rclnodejs / 25727559523

12 May 2026 10:04AM UTC coverage: 85.474% (+0.08%) from 85.399%
25727559523

push

github

web-flow
[Web Runtime] rclnodejs-web CLI launcher (#1513)

Adds the bundled `rclnodejs-web` (and `rcl-web` short alias) CLI so frontend developers can run the Web Runtime without writing a Node.js server.

- `bin/rclnodejs-web.js` (new, 138 LOC) — declarative launcher. Reads a JSON config (`web.json`) and/or repeatable `--call` / `--publish` / `--subscribe` flags. Always starts the WebSocket transport; opt-in HTTP via `--http-port` (or an `http: { port }` block in the config file). Defers `require('rclnodejs')` until after argv parsing so `--help` / `--version` stay fast. Supports ephemeral `--port 0` / `--http-port 0` for tests. Handles SIGINT/SIGTERM with `runtime.stop()` + `rclnodejs.shutdown()`.

- `lib/runtime/cli-config.js` (new, 339 LOC) — pure-data argv parser + JSON config loader + validator + defaults merger. Independent of rclnodejs so it can be unit-tested without spinning up a node. Exports `DEFAULTS`, `parseArgv`, `loadConfigFile`, `validateConfig`, `mergeConfig`, `CliError`, `HELP`. Defaults: `port=9000`, `host='::'` (dual-stack), `path='/capability'`, `node='rclnodejs_web'`, `http.port=null` (disabled).

- `test/test-web-cli.js` (new, 459 LOC) — 25 mocha cases: parseArgv (8), config-file load + validate including the `http` block (10), mergeConfig precedence (5), and end-to-end CLI launches that probe the runtime via raw WebSocket and HTTP `fetch` (3).

- `package.json` — two `bin` entries: `rclnodejs-web` (canonical) and `rcl-web` (short alias).

Fix: #1510

1828 of 2323 branches covered (78.69%)

Branch coverage included in aggregate %.

103 of 115 new or added lines in 1 file covered. (89.57%)

3609 of 4038 relevant lines covered (89.38%)

396.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.94
/lib/runtime/cli-config.js
1
// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//     http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Programmatic config loader for the `rclnodejs-web` CLI.
10
//
11
// Pure-data; does not import rclnodejs. Kept separate from bin/ so it
12
// can be unit-tested without spinning up a node.
13

14
'use strict';
15

16
const fs = require('node:fs');
3✔
17
const path = require('node:path');
3✔
18

19
const DEFAULTS = Object.freeze({
3✔
20
  port: 9000,
21
  // '::' = dual-stack: accepts both IPv6 and IPv4-mapped connections.
22
  // Matches Node's http server default and avoids browser "localhost"
23
  // mismatches on WSL2 / glibc systems where localhost resolves to
24
  // ::1 first. Pass --host 0.0.0.0 to bind IPv4 only.
25
  host: '::',
26
  path: '/capability',
27
  node: 'rclnodejs_web',
28
  // Optional HTTP transport for `call`/`publish`. Disabled by default;
29
  // set `port` (or pass `--http-port` on the CLI) to turn it on.
30
  // `host` defaults to the same dual-stack address as the WS listener.
31
  // `basePath` defaults to `/capability`.
32
  http: { port: null, host: null, basePath: null },
33
  expose: { call: {}, publish: {}, subscribe: {} },
34
});
35

36
const KINDS = ['call', 'publish', 'subscribe'];
3✔
37

38
/**
39
 * Parse command-line argv (without the leading `node`/script entries)
40
 * into a partial config object. Flags here override anything from a
41
 * config file later via {@link mergeConfig}.
42
 *
43
 * @param {string[]} argv
44
 * @returns {{configPath?: string, partial: object, help?: boolean, version?: boolean, quiet?: boolean}}
45
 */
46
function parseArgv(argv) {
47
  const partial = {
13✔
48
    expose: { call: {}, publish: {}, subscribe: {} },
49
    http: {},
50
  };
51
  const out = { partial };
13✔
52
  let i = 0;
13✔
53
  while (i < argv.length) {
13✔
54
    const a = argv[i];
23✔
55
    const eat = (name) => {
23✔
56
      const v = argv[i + 1];
16✔
57
      if (v === undefined || v.startsWith('-')) {
16!
NEW
58
        throw new CliError(`flag ${name} requires a value`);
×
59
      }
60
      i += 2;
16✔
61
      return v;
16✔
62
    };
63
    if (a === '--help' || a === '-h') {
23✔
64
      out.help = true;
3✔
65
      i++;
3✔
66
    } else if (a === '--version' || a === '-V') {
20✔
67
      out.version = true;
1✔
68
      i++;
1✔
69
    } else if (a === '--quiet' || a === '-q') {
19✔
70
      out.quiet = true;
1✔
71
      i++;
1✔
72
    } else if (a === '--port' || a === '-p') {
18✔
73
      partial.port = Number(eat(a));
3✔
74
    } else if (a === '--host') {
15✔
75
      partial.host = eat(a);
2✔
76
    } else if (a === '--path') {
13✔
77
      partial.path = eat(a);
1✔
78
    } else if (a === '--node-name' || a === '--node') {
12✔
79
      partial.node = eat(a);
2✔
80
    } else if (a === '--http-port') {
10✔
81
      partial.http.port = Number(eat(a));
1✔
82
    } else if (a === '--http-host') {
9✔
83
      partial.http.host = eat(a);
1✔
84
    } else if (a === '--http-base-path') {
8✔
85
      partial.http.basePath = eat(a);
1✔
86
    } else if (a === '--call' || a === '--publish' || a === '--subscribe') {
7✔
87
      const kind = a.slice(2);
5✔
88
      const pair = eat(a);
5✔
89
      const eq = pair.indexOf('=');
5✔
90
      if (eq <= 0) {
5✔
91
        throw new CliError(
1✔
92
          `${a} expects <name>=<type> (got ${JSON.stringify(pair)})`
93
        );
94
      }
95
      partial.expose[kind][pair.slice(0, eq)] = pair.slice(eq + 1);
4✔
96
    } else if (a.startsWith('-')) {
2✔
97
      throw new CliError(`unknown flag: ${a}`);
1✔
98
    } else if (!out.configPath) {
1!
99
      out.configPath = a;
1✔
100
      i++;
1✔
101
    } else {
NEW
102
      throw new CliError(`unexpected positional argument: ${a}`);
×
103
    }
104
  }
105
  return out;
11✔
106
}
107

108
/**
109
 * Load and validate a JSON config file. Returns an empty object if no
110
 * path is given.
111
 *
112
 * @param {string|undefined} configPath
113
 * @returns {object}
114
 */
115
function loadConfigFile(configPath) {
116
  if (!configPath) return {};
5✔
117
  const abs = path.resolve(configPath);
3✔
118
  let raw;
119
  try {
3✔
120
    raw = fs.readFileSync(abs, 'utf8');
3✔
121
  } catch (e) {
122
    throw new CliError(`cannot read config: ${abs}: ${e.message}`);
1✔
123
  }
124
  let cfg;
125
  try {
2✔
126
    cfg = JSON.parse(raw);
2✔
127
  } catch (e) {
128
    throw new CliError(`invalid JSON in ${abs}: ${e.message}`);
1✔
129
  }
130
  validateConfig(cfg, abs);
1✔
131
  return cfg;
1✔
132
}
133

134
/**
135
 * Shape-check a parsed config. Throws on bad shape with a precise
136
 * message; returns nothing on success.
137
 *
138
 * @param {*} cfg
139
 * @param {string} [origin]
140
 */
141
function validateConfig(cfg, origin = 'config') {
5✔
142
  if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
6!
NEW
143
    throw new CliError(`${origin}: top-level value must be a JSON object`);
×
144
  }
145
  if (cfg.port !== undefined && !Number.isFinite(cfg.port)) {
6!
NEW
146
    throw new CliError(`${origin}: "port" must be a number`);
×
147
  }
148
  if (cfg.host !== undefined && typeof cfg.host !== 'string') {
6!
NEW
149
    throw new CliError(`${origin}: "host" must be a string`);
×
150
  }
151
  if (cfg.path !== undefined && typeof cfg.path !== 'string') {
6!
NEW
152
    throw new CliError(`${origin}: "path" must be a string`);
×
153
  }
154
  if (cfg.node !== undefined && typeof cfg.node !== 'string') {
6!
NEW
155
    throw new CliError(`${origin}: "node" must be a string`);
×
156
  }
157
  if (cfg.http !== undefined) {
6✔
158
    if (
3✔
159
      cfg.http === null ||
9✔
160
      typeof cfg.http !== 'object' ||
161
      Array.isArray(cfg.http)
162
    ) {
163
      throw new CliError(`${origin}: "http" must be an object`);
1✔
164
    }
165
    if (
2✔
166
      cfg.http.port !== undefined &&
6✔
167
      cfg.http.port !== null &&
168
      !Number.isFinite(cfg.http.port)
169
    ) {
170
      throw new CliError(`${origin}: "http.port" must be a number`);
1✔
171
    }
172
    if (
1!
173
      cfg.http.host !== undefined &&
3✔
174
      cfg.http.host !== null &&
175
      typeof cfg.http.host !== 'string'
176
    ) {
NEW
177
      throw new CliError(`${origin}: "http.host" must be a string`);
×
178
    }
179
    if (
1!
180
      cfg.http.basePath !== undefined &&
3✔
181
      cfg.http.basePath !== null &&
182
      typeof cfg.http.basePath !== 'string'
183
    ) {
NEW
184
      throw new CliError(`${origin}: "http.basePath" must be a string`);
×
185
    }
186
  }
187
  if (cfg.expose !== undefined) {
4✔
188
    if (
3!
189
      cfg.expose === null ||
9✔
190
      typeof cfg.expose !== 'object' ||
191
      Array.isArray(cfg.expose)
192
    ) {
NEW
193
      throw new CliError(`${origin}: "expose" must be an object`);
×
194
    }
195
    for (const kind of Object.keys(cfg.expose)) {
3✔
196
      if (!KINDS.includes(kind)) {
3✔
197
        throw new CliError(
1✔
198
          `${origin}: "expose.${kind}" — unknown kind; allowed: ${KINDS.join(', ')}`
199
        );
200
      }
201
      const m = cfg.expose[kind];
2✔
202
      if (m === null || typeof m !== 'object' || Array.isArray(m)) {
2!
NEW
203
        throw new CliError(`${origin}: "expose.${kind}" must be an object`);
×
204
      }
205
      for (const [name, value] of Object.entries(m)) {
2✔
206
        // Accept either the shorthand string form or the rich
207
        // `{ type: string, ... }` form. The rich form's extra keys are
208
        // reserved for forward-compatible per-capability metadata
209
        // (e.g. QoS); the validator only insists on a non-empty type.
210
        if (typeof value === 'string') {
2✔
211
          if (!value) {
1!
NEW
212
            throw new CliError(
×
213
              `${origin}: expose.${kind}["${name}"] must be a non-empty string`
214
            );
215
          }
216
        } else if (
1!
217
          value &&
2!
218
          typeof value === 'object' &&
219
          !Array.isArray(value) &&
220
          typeof value.type === 'string' &&
221
          value.type
222
        ) {
223
          // ok — rich form with at least { type }
224
        } else {
225
          throw new CliError(
1✔
226
            `${origin}: expose.${kind}["${name}"] must be a non-empty string or { "type": "<pkg>/<kind>/<Name>" }`
227
          );
228
        }
229
      }
230
    }
231
  }
232
}
233

234
/**
235
 * Merge defaults ⊕ file config ⊕ CLI partial into a fully-resolved
236
 * config. Later sources win.
237
 */
238
function mergeConfig(...sources) {
239
  const out = JSON.parse(JSON.stringify(DEFAULTS));
6✔
240
  for (const src of sources) {
6✔
241
    if (!src) continue;
12!
242
    for (const k of ['port', 'host', 'path', 'node']) {
12✔
243
      if (src[k] !== undefined) out[k] = src[k];
48✔
244
    }
245
    if (src.http) {
12✔
246
      for (const k of ['port', 'host', 'basePath']) {
3✔
247
        if (src.http[k] !== undefined && src.http[k] !== null) {
9✔
248
          out.http[k] = src.http[k];
3✔
249
        }
250
      }
251
    }
252
    if (src.expose) {
12✔
253
      for (const kind of KINDS) {
3✔
254
        if (src.expose[kind]) {
9✔
255
          Object.assign(out.expose[kind], src.expose[kind]);
5✔
256
        }
257
      }
258
    }
259
  }
260
  return out;
6✔
261
}
262

263
class CliError extends Error {
264
  constructor(message) {
265
    super(message);
8✔
266
    this.name = 'CliError';
8✔
267
    this.cli = true;
8✔
268
  }
269
}
270

271
const HELP = `Usage: rclnodejs-web [config.json] [options]
3✔
272

273
  rclnodejs/web — typed Web SDK and capability runtime for ROS 2.
274

275
  Start a runtime that exposes a declarative allow-list of ROS 2
276
  topics and services to browsers. Speaks both WebSocket (always
277
  on) and HTTP (opt-in via --http-port).
278

279
WebSocket transport (always on):
280
  -p, --port <n>              WS listen port           (default 9000)
281
      --host <addr>           WS bind host             (default ::, dual-stack)
282
      --path <url>            WS URL path              (default /capability)
283

284
HTTP transport (opt-in; for call/publish only):
285
      --http-port <n>         HTTP listen port         (disabled if omitted)
286
      --http-host <addr>      HTTP bind host           (default same as --host)
287
      --http-base-path <p>    HTTP base path           (default /capability)
288

289
Capabilities:
290
      --call <name>=<type>    expose a service capability   (repeatable)
291
      --publish <name>=<type> expose a topic publish        (repeatable)
292
      --subscribe <n>=<type>  expose a topic subscription   (repeatable)
293

294
Other:
295
      --node-name <name>      ROS 2 node name          (default rclnodejs_web)
296
  -q, --quiet                 don't print the startup banner
297
  -h, --help                  show this help
298
  -V, --version               show rclnodejs version
299

300
Examples:
301
  rclnodejs-web --port 9000 \\
302
    --call /add_two_ints=example_interfaces/srv/AddTwoInts \\
303
    --subscribe /scan=sensor_msgs/msg/LaserScan
304

305
  # Add HTTP for call/publish on :9001 (curl-able):
306
  rclnodejs-web --port 9000 --http-port 9001 \\
307
    --call /add_two_ints=example_interfaces/srv/AddTwoInts
308

309
  # Or all from a JSON config:
310
  rclnodejs-web web.json
311

312
Config-file shape (every field optional):
313
  {
314
    "port": 9000, "host": "::", "path": "/capability",
315
    "http": { "port": 9001 },
316
    "expose": {
317
      "call":      { "/add_two_ints": "example_interfaces/srv/AddTwoInts" },
318
      "publish":   { "/cmd_vel":      "geometry_msgs/msg/Twist" },
319
      "subscribe": { "/scan":         "sensor_msgs/msg/LaserScan" }
320
    }
321
  }
322

323
Notes:
324
  The startup banner ("rclnodejs/web listening on …") is human-readable
325
  only; its exact wording may change between minor versions. Scripts
326
  and CI jobs should run with --quiet and rely on the exit status or
327
  the explicit --port value rather than scraping the banner.
328
`;
329

330
module.exports = {
3✔
331
  DEFAULTS,
332
  KINDS,
333
  CliError,
334
  HELP,
335
  parseArgv,
336
  loadConfigFile,
337
  validateConfig,
338
  mergeConfig,
339
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc