hello-lights 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,6 +99,34 @@ $ npm run cli -- serve --port 3000 # start the HTTP server on a custom port
99
99
 
100
100
  Use `--help` for the full list of options, including `--serial-num` to target a specific device and `--selector multi` to control multiple traffic lights at once.
101
101
 
102
+ ## HTTP Server REST API
103
+
104
+ The `serve` command starts an HTTP server that exposes the Commander interface as a REST API. By default it listens on port 9000.
105
+
106
+ | Endpoint | Method | Body | Response |
107
+ |---|---|---|---|
108
+ | `/run` | POST | Command string (plain text) | 202 Accepted |
109
+ | `/run?reset=true` | POST | Command string (plain text) | 202 Accepted (resets lights first) |
110
+ | `/cancel` | POST | — | 200 OK |
111
+ | `/definitions` | POST | Definition string (plain text) | 202 Accepted |
112
+ | `/commands` | GET | — | 200 + JSON array of command names |
113
+ | `/commands/:name` | GET | — | 200 + help text (`text/x-ansi`) or 404 |
114
+ | `/info` | GET | — | 200 + JSON array of `{ serialNum, status }` |
115
+
116
+ Examples:
117
+
118
+ ```sh
119
+ $ curl -X POST http://localhost:9000/run -d 'blink 3 green 300'
120
+ $ curl -X POST http://localhost:9000/run?reset=true -d 'twinkle red 400'
121
+ $ curl -X POST http://localhost:9000/cancel
122
+ $ curl -X POST http://localhost:9000/definitions -d '(def foo (blink 1 green 300))'
123
+ $ curl http://localhost:9000/commands
124
+ $ curl http://localhost:9000/commands/turn
125
+ $ curl http://localhost:9000/info
126
+ ```
127
+
128
+ The server also serves a browser demo at the root URL (`/`).
129
+
102
130
  ## Running as a Linux Service
103
131
 
104
132
  You can set up `hello-lights serve` to run automatically as a systemd user service on Linux.
@@ -13,8 +13,8 @@ class CommanderRepl {
13
13
  this.manager = this.commander.manager;
14
14
  }
15
15
 
16
- formatCommandNames() {
17
- let names = this.commander.commandNames;
16
+ async formatCommandNames() {
17
+ let names = await this.commander.fetchCommandNames();
18
18
  let parts = [' '];
19
19
  names.forEach((name, i) => {
20
20
  parts.push(` ${name}`);
@@ -23,7 +23,7 @@ class CommanderRepl {
23
23
  return parts.join('');
24
24
  }
25
25
 
26
- help(commandName) {
26
+ async help(commandName) {
27
27
  if (commandName === undefined) {
28
28
  this.logger.log([
29
29
  `Commands for the traffic light`,
@@ -34,30 +34,30 @@ class CommanderRepl {
34
34
  `> [command]`,
35
35
  `> { [... multi line command] }`,
36
36
  ` available commands:`,
37
- this.formatCommandNames()
37
+ await this.formatCommandNames()
38
38
  ].join('\n'));
39
39
  } else {
40
- this.commander.help(commandName);
40
+ await this.commander.help(commandName);
41
41
  }
42
42
  }
43
43
 
44
- execute(text, context, filename, callback) {
44
+ async execute(text, context, filename, callback) {
45
45
  text = text.trim();
46
46
  let match;
47
47
  if (!text) {
48
48
  } else if (text === 'cancel') {
49
- this.commander.cancel();
49
+ await this.commander.cancel();
50
50
  } else if (text === 'help') {
51
- this.help();
51
+ await this.help();
52
52
  } else if (match = text.match(/^help\s+(.+)/)) { // eslint-disable-line no-cond-assign
53
- this.help(match[1]);
53
+ await this.help(match[1]);
54
54
  } else if (text === 'exit' || text === 'quit') {
55
- this.commander.cancel();
55
+ await this.commander.cancel();
56
56
  this.commander.close();
57
57
  this.logger.log('Bye');
58
58
  process.exit(0);
59
59
  } else if (text === 'check device') {
60
- this.commander.logInfo();
60
+ await this.commander.logInfo();
61
61
  } else if (this.supportsNewDevice() && text === 'new device') {
62
62
  this.newDevice();
63
63
  } else if (text.startsWith('{')) {
@@ -10,14 +10,44 @@ function createApp(commander) {
10
10
  const app = express();
11
11
 
12
12
  app.use(express.static(path.join(__dirname, 'public')));
13
+ app.use(express.text({type: '*/*'}));
14
+
13
15
  app.post('/run', (req, res) => {
14
- let commandStr = '';
15
- req.on('data', data => commandStr += data);
16
- req.on('end', () => {
17
- commander.run(commandStr);
18
- res.statusCode = 202; // accepted
19
- res.end();
20
- });
16
+ let reset = req.query.reset === 'true';
17
+ commander.run(req.body || '', reset);
18
+ res.sendStatus(202);
19
+ });
20
+
21
+ app.post('/cancel', async (req, res) => {
22
+ await commander.cancel();
23
+ res.sendStatus(200);
24
+ });
25
+
26
+ app.post('/definitions', (req, res) => {
27
+ commander.runDefinitions(req.body || '');
28
+ res.sendStatus(202);
29
+ });
30
+
31
+ app.get('/commands', async (req, res) => {
32
+ res.json(await commander.fetchCommandNames());
33
+ });
34
+
35
+ app.get('/commands/:name', (req, res) => {
36
+ let command = commander.interpreter.lookup(req.params.name);
37
+ if (!command) {
38
+ res.status(404).send(`Command not found: "${req.params.name}"`);
39
+ return;
40
+ }
41
+ let helpText = commander.formatter.format(command.meta);
42
+ res.type('text/x-ansi').send(helpText);
43
+ });
44
+
45
+ app.get('/info', (req, res) => {
46
+ if (commander.manager) {
47
+ res.json(commander.manager.info());
48
+ } else {
49
+ res.json([]);
50
+ }
21
51
  });
22
52
 
23
53
  return app;
package/lib/commander.js CHANGED
@@ -89,7 +89,7 @@ class Commander {
89
89
  /**
90
90
  * Cancels any currently executing command.
91
91
  */
92
- cancel() {
92
+ async cancel() {
93
93
  this.interpreter.cancel();
94
94
  }
95
95
 
@@ -248,11 +248,19 @@ class Commander {
248
248
  return this.interpreter.commands;
249
249
  }
250
250
 
251
+ /**
252
+ * Fetches all supported command names.
253
+ * @returns {Promise<string[]>} A promise that resolves with the command names.
254
+ */
255
+ async fetchCommandNames() {
256
+ return this.interpreter.commandNames;
257
+ }
258
+
251
259
  /**
252
260
  * Logs the help info for the given command name.
253
261
  * @param {string} commandName - Name of the command to log help info.
254
262
  */
255
- help(commandName) {
263
+ async help(commandName) {
256
264
  let command = this.interpreter.lookup(commandName);
257
265
  if (!command) {
258
266
  this.logger.error(`Command not found: "${commandName}"`);
@@ -264,7 +272,7 @@ class Commander {
264
272
  /**
265
273
  * Logs information about known traffic lights.
266
274
  */
267
- logInfo() {
275
+ async logInfo() {
268
276
  this.selector.logInfo(this.logger);
269
277
  }
270
278
 
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const {Commander} = require('./commander');
2
+ const {RestCommander} = require('./rest-commander');
2
3
 
3
4
  module.exports = {
4
5
  commands: require('./commands'),
@@ -6,5 +7,6 @@ module.exports = {
6
7
  physical: require('./physical'),
7
8
  devices: require('./devices'),
8
9
  selectors: require('./selectors'),
9
- Commander
10
+ Commander,
11
+ RestCommander
10
12
  };
@@ -0,0 +1,126 @@
1
+ ////////////////////////////////////////////////
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+
6
+ ////////////////////////////////////////////////
7
+
8
+ function request(baseUrl, method, path, body) {
9
+ return new Promise((resolve, reject) => {
10
+ let url = new URL(path, baseUrl);
11
+ let client = url.protocol === 'https:' ? https : http;
12
+ let options = {
13
+ method,
14
+ hostname: url.hostname,
15
+ port: url.port,
16
+ path: url.pathname + url.search,
17
+ headers: {}
18
+ };
19
+ if (body != null) {
20
+ options.headers['Content-Type'] = 'text/plain';
21
+ }
22
+ let req = client.request(options, (res) => {
23
+ let data = '';
24
+ res.on('data', chunk => data += chunk);
25
+ res.on('end', () => resolve({statusCode: res.statusCode, body: data}));
26
+ });
27
+ req.on('error', reject);
28
+ if (body != null) req.write(body);
29
+ req.end();
30
+ });
31
+ }
32
+
33
+ ////////////////////////////////////////////////
34
+
35
+ /**
36
+ * A Commander client that controls a traffic light via a remote REST API.
37
+ * Communicates with a server started by the `serve` command.
38
+ */
39
+ class RestCommander {
40
+
41
+ /**
42
+ * Creates a new RestCommander instance.
43
+ * @param {object} [options] - Options.
44
+ * @param {string} [options.host='http://localhost:9000'] - The base URL of
45
+ * the remote server (e.g. `http://localhost:9000`).
46
+ * @param {object} [options.logger=console] - A Console-like object for logging.
47
+ */
48
+ constructor({host = 'http://localhost:9000', logger = console} = {}) {
49
+ this.host = host.replace(/\/+$/, ''); // strip trailing slashes
50
+ this.logger = logger;
51
+ }
52
+
53
+ /**
54
+ * Executes a command on the remote server (fire-and-forget).
55
+ * @param {string} command - Command to execute.
56
+ * @param {boolean} [reset=false] - Whether to reset the traffic light first.
57
+ */
58
+ async run(command, reset = false) {
59
+ let path = reset ? '/run?reset=true' : '/run';
60
+ await request(this.host, 'POST', path, command);
61
+ }
62
+
63
+ /**
64
+ * Cancels any currently executing command on the remote server.
65
+ */
66
+ async cancel() {
67
+ await request(this.host, 'POST', '/cancel');
68
+ }
69
+
70
+ /**
71
+ * Executes definition-only commands on the remote server.
72
+ * @param {string} command - Command with definitions to execute.
73
+ */
74
+ async runDefinitions(command) {
75
+ await request(this.host, 'POST', '/definitions', command);
76
+ }
77
+
78
+ /**
79
+ * Fetches all available command names from the remote server.
80
+ * @returns {Promise<string[]>} Array of command names.
81
+ */
82
+ async fetchCommandNames() {
83
+ let res = await request(this.host, 'GET', '/commands');
84
+ return JSON.parse(res.body);
85
+ }
86
+
87
+ /**
88
+ * Logs the help info for the given command name from the remote server.
89
+ * @param {string} commandName - Name of the command.
90
+ */
91
+ async help(commandName) {
92
+ let res = await request(this.host, 'GET', `/commands/${encodeURIComponent(commandName)}`);
93
+ if (res.statusCode === 404) {
94
+ this.logger.error(res.body);
95
+ } else {
96
+ this.logger.log(res.body);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Logs information about known traffic lights from the remote server.
102
+ */
103
+ async logInfo() {
104
+ let res = await request(this.host, 'GET', '/info');
105
+ let devicesInfo = JSON.parse(res.body);
106
+ if (devicesInfo.length === 0) {
107
+ this.logger.log('No devices found');
108
+ } else {
109
+ this.logger.log('Known devices:');
110
+ devicesInfo.forEach(info =>
111
+ this.logger.log(`device ${info.serialNum}: ${info.status}`));
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Closes this instance. No-op for REST client.
117
+ */
118
+ close() {}
119
+
120
+ }
121
+
122
+ ////////////////////////////////////////////////
123
+
124
+ module.exports = {RestCommander};
125
+
126
+ ////////////////////////////////////////////////
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hello-lights",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Commands to control a traffic light",
5
5
  "main": "index.js",
6
6
  "bin": {