configurapi-runner-ws 1.12.0 → 1.14.0

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/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "configurapi-runner-ws",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Websocket runner for configurapi.",
5
5
  "bin": {
6
6
  "configurapi-runner-ws": "src/app.mjs"
7
7
  },
8
8
  "main": "src/index.js",
9
9
  "scripts": {
10
- "test": "cd test && mocha ./"
10
+ "test": "node --test --test-reporter=spec spec/**/*.spec.js",
11
+ "test-watch": "node --test --watch --test-reporter=spec spec/**/*.spec.js"
11
12
  },
12
13
  "files": [
13
14
  "src/*"
@@ -33,5 +34,9 @@
33
34
  "dotenv": "^17.2.3",
34
35
  "one-liner": "^1.3.0",
35
36
  "ws": "^8.18.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/chai": "^5.2.3",
40
+ "chai": "^6.2.2"
36
41
  }
37
42
  }
package/src/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  require('dotenv').config();
2
2
  const http = require('http');
3
3
  const https = require('https');
4
- const WsAdapter = require('./wsAdapter');
5
4
  const events = require('events');
6
5
  const Configurapi = require('configurapi');
7
6
  const { WebSocketServer } = require('ws');
@@ -11,7 +10,7 @@ const oneliner = require('one-liner');
11
10
  const wsAdapter = require('./wsAdapter');
12
11
  const URL = require('url');
13
12
 
14
- function addInternalRoute(self, config, routeName,)
13
+ function addInternalRoute(self, config, routeName)
15
14
  {
16
15
  let route = new Route();
17
16
  route.name = routeName;
@@ -27,7 +26,7 @@ function addInternalRoute(self, config, routeName,)
27
26
  config.events.set(route.name, route);
28
27
  }
29
28
 
30
- const connections = []
29
+ const connections = new Map()
31
30
 
32
31
  module.exports = class HttpRunner extends events.EventEmitter
33
32
  {
@@ -55,7 +54,7 @@ module.exports = class HttpRunner extends events.EventEmitter
55
54
  loader.handlers.set('list_@connections', (e)=>
56
55
  {
57
56
  e.response.headers['Content-Type'] = 'application/json';
58
- e.response.body = Object.keys(connections);
57
+ e.response.body = Array.from(connections.keys());
59
58
  });
60
59
  loader.handlers.set('post_@connection', async (e)=>
61
60
  {
@@ -63,10 +62,10 @@ module.exports = class HttpRunner extends events.EventEmitter
63
62
 
64
63
  const connectionId = e.params?.['@connection'];
65
64
 
66
- if(connectionId && connections[connectionId])
65
+ if(connectionId && connections.has(connectionId))
67
66
  {
68
67
  let wsResponse = new Response(e.payload, 200, {...e.request.headers, 'connection-id': connectionId})
69
- await wsAdapter.write(connections[connectionId].ws, wsResponse)
68
+ await wsAdapter.write(connections.get(connectionId).ws, wsResponse)
70
69
  e.response.statusCode = 204
71
70
  }
72
71
  else
@@ -80,9 +79,9 @@ module.exports = class HttpRunner extends events.EventEmitter
80
79
  addInternalRoute(this, config, 'post_@connection')
81
80
 
82
81
  this.service = new Configurapi.Service(config, loader);
83
- this.service.on("trace", (s) => this.emit("trace", s));
84
- this.service.on("debug", (s) => this.emit("debug", s));
85
- this.service.on("error", (s) => this.emit("error", s));
82
+ this.service.on(LogLevel.Trace, (s) => this.emit(LogLevel.Trace, s));
83
+ this.service.on(LogLevel.Debug, (s) => this.emit(LogLevel.Debug, s));
84
+ this.service.on(LogLevel.Error, (s) => this.emit(LogLevel.Error, s));
86
85
  await this.service.init();
87
86
 
88
87
  let httpServer;
@@ -94,7 +93,7 @@ module.exports = class HttpRunner extends events.EventEmitter
94
93
  httpServer.keepAliveTimeout = this.keepAliveTimeout;
95
94
  httpServer.listen(this.sPort);
96
95
 
97
- this.emit("trace", "Secure Server is listening...");
96
+ this.emit(LogLevel.Trace, "Secure Server is listening...");
98
97
  }
99
98
  else
100
99
  {
@@ -103,7 +102,7 @@ module.exports = class HttpRunner extends events.EventEmitter
103
102
  httpServer.keepAliveTimeout = this.keepAliveTimeout;
104
103
  httpServer.listen(this.port);
105
104
 
106
- this.emit("trace", "Server is listening..."+this.port);
105
+ this.emit(LogLevel.Trace, "Server is listening..."+this.port);
107
106
  }
108
107
 
109
108
  let managementServer = http.createServer({key: this.key, cert: this.cert}, (req, resp) => {
@@ -112,7 +111,7 @@ module.exports = class HttpRunner extends events.EventEmitter
112
111
  managementServer.keepAliveTimeout = this.keepAliveTimeout;
113
112
  managementServer.listen(this.mPort);
114
113
 
115
- this.emit('trace', 'Management server is listening...'+this.mPort);
114
+ this.emit(LogLevel.Trace, 'Management server is listening...'+this.mPort);
116
115
 
117
116
  const wss = new WebSocketServer({ server: httpServer, path: "/ws", handleProtocols: (protocols, req) => {
118
117
  if(protocols === undefined)
@@ -136,45 +135,65 @@ module.exports = class HttpRunner extends events.EventEmitter
136
135
  const connectionId = randomUUID();
137
136
  ws.connectionId = connectionId;
138
137
 
139
- connections[connectionId] = {
140
- connectionId,
141
- ws
142
- }
143
- this.emit("trace", `[connect] ${connectionId}`);
138
+ connections.set(connectionId, { connectionId, ws });
139
+
140
+ this.emit(LogLevel.Trace, `[connect] ${connectionId}`);
144
141
 
145
- if (config.events.has('on_connect'))
146
- {
147
- const response = await this._requestListener(ws, undefined, 'on_connect');
148
-
149
- if (response.statusCode >= 400)
150
- {
151
- return ws.close(1008, 'Policy Violation');
152
- }
153
- }
154
142
  ws.on("message", async (data) =>
155
143
  {
156
144
  const incomingMessage = typeof data === "string" ? data : data.toString("utf8");
157
145
 
158
- await this._requestListener(ws, incomingMessage);
146
+ try
147
+ {
148
+ await this._requestListener(ws, incomingMessage);
149
+ }
150
+ catch(err)
151
+ {
152
+ this.emit(LogLevel.Error, err);
153
+ }
159
154
  });
160
155
 
161
156
  ws.on("close", async(code, reason) =>
162
157
  {
163
- await (config.events.has('on_disconnect') ? this._requestListener(ws, undefined, 'on_disconnect') : undefined);
158
+ try
159
+ {
160
+ await (config.events.has('on_disconnect') ? this._requestListener(ws, undefined, 'on_disconnect') : undefined);
161
+ }
162
+ catch(err)
163
+ {
164
+ this.emit(LogLevel.Error, err);
165
+ }
164
166
 
165
167
  const reasonMessage = reason && reason.length ? reason.toString("utf8") : "";
166
168
 
167
- delete connections[connectionId];
169
+ connections.delete(connectionId);
168
170
 
169
- this.emit("trace", `[disconnect] ${connectionId} (${code}${reasonMessage ? ` - ${reasonMessage}` : ""})`);
171
+ this.emit(LogLevel.Trace, `[disconnect] ${connectionId} (${code}${reasonMessage ? ` - ${reasonMessage}` : ""})`);
170
172
  });
171
173
 
172
174
  ws.on("error", (err) =>
173
175
  {
174
176
  // Note: 'error' will often be followed by 'close'
175
177
  const message = err instanceof Error ? err.message : err;
176
- this.emit("error", `${connectionId} - ${message}`);
178
+ this.emit(LogLevel.Error, `${connectionId} - ${message}`);
177
179
  });
180
+
181
+ if (config.events.has('on_connect'))
182
+ {
183
+ try
184
+ {
185
+ const response = await this._requestListener(ws, undefined, 'on_connect');
186
+
187
+ if (response.statusCode >= 400)
188
+ {
189
+ return ws.close(1008, 'Policy Violation');
190
+ }
191
+ }
192
+ catch(err)
193
+ {
194
+ this.emit(LogLevel.Error, err);
195
+ }
196
+ }
178
197
  });
179
198
  }
180
199
 
@@ -197,7 +216,7 @@ module.exports = class HttpRunner extends events.EventEmitter
197
216
 
198
217
  try
199
218
  {
200
- event = new Configurapi.Event(await WsAdapter.toRequest(ws, incomingMessage));
219
+ event = new Configurapi.Event(await wsAdapter.toRequest(ws, incomingMessage));
201
220
 
202
221
  if(customEventName)
203
222
  {
@@ -216,7 +235,7 @@ module.exports = class HttpRunner extends events.EventEmitter
216
235
  event.response.headers['Sec-WebSocket-Protocol'] = protocol;
217
236
  }
218
237
 
219
- await WsAdapter.write(ws, event.response);
238
+ await wsAdapter.write(ws, event.response);
220
239
  }
221
240
  else
222
241
  {
@@ -228,7 +247,7 @@ module.exports = class HttpRunner extends events.EventEmitter
228
247
  }
229
248
  event.response.headers['message-id'] = event.request.headers?.['message-id'];
230
249
 
231
- await WsAdapter.write(ws, event.response); // Send response back to the caller.
250
+ await wsAdapter.write(ws, event.response); // Send response back to the caller.
232
251
  }
233
252
 
234
253
  return event.response;
@@ -245,13 +264,13 @@ module.exports = class HttpRunner extends events.EventEmitter
245
264
 
246
265
  try
247
266
  {
248
- await WsAdapter.write(ws, response);
267
+ await wsAdapter.write(ws, response);
249
268
  }
250
269
  catch(err)
251
270
  {
252
271
  try
253
272
  {
254
- this.emit("error", JSON.stringify(err));
273
+ this.emit(LogLevel.Error, JSON.stringify(err));
255
274
  }
256
275
  catch(errorFromListner)
257
276
  {
package/src/wsAdapter.js CHANGED
@@ -12,6 +12,26 @@ module.exports = {
12
12
 
13
13
  await new Promise((resolve, reject)=>
14
14
  {
15
+ let settled = false;
16
+
17
+ const safeResolve = () =>
18
+ {
19
+ if(!settled)
20
+ {
21
+ settled = true;
22
+ resolve();
23
+ }
24
+ };
25
+
26
+ const safeReject = (err) =>
27
+ {
28
+ if(!settled)
29
+ {
30
+ settled = true;
31
+ reject(err);
32
+ }
33
+ };
34
+
15
35
  try
16
36
  {
17
37
  if(message instanceof StreamResponse)
@@ -22,7 +42,7 @@ module.exports = {
22
42
  // Guard: need a readable stream
23
43
  if(!stream || typeof stream.on !== 'function')
24
44
  {
25
- return reject(new Error('StreamResponse.stream is not a readable stream.'));
45
+ return safeReject(new Error('StreamResponse.stream is not a readable stream.'));
26
46
  }
27
47
 
28
48
  const HIGH_WATER = 64 * 1024;
@@ -40,7 +60,11 @@ module.exports = {
40
60
  {
41
61
  stream.pause();
42
62
  const check = () => {
43
- if (ws.readyState !== WebSocket.OPEN) return cleanup();
63
+ if (ws.readyState !== WebSocket.OPEN)
64
+ {
65
+ cleanup();
66
+ return safeResolve();
67
+ }
44
68
  if (ws.bufferedAmount <= HIGH_WATER) return stream.resume();
45
69
  setTimeout(check, 100);
46
70
  };
@@ -52,13 +76,13 @@ module.exports = {
52
76
 
53
77
  ws.send(JSON.stringify(response), (err) =>
54
78
  {
55
- if (err) { cleanup(); return reject(err); }
79
+ if (err) { cleanup(); return safeReject(err); }
56
80
  });
57
81
 
58
82
  bucket = '';
59
83
  bucketBytes = 0;
60
84
  };
61
-
85
+
62
86
  const cleanup = () =>
63
87
  {
64
88
  try {
@@ -66,9 +90,31 @@ module.exports = {
66
90
  stream.removeListener('data', onData);
67
91
  stream.removeListener('end', onEnd);
68
92
  stream.removeListener('error', onErr);
93
+ stream.removeListener('close', onClose);
94
+
95
+ ws.removeListener('close', onWsClose);
96
+ ws.removeListener('error', onWsError);
97
+
98
+ // safeResolve();
69
99
  } catch(_) {}
70
100
  };
71
101
 
102
+ // If ws connection closes before the stream does, cleanup the connection.
103
+ const onWsClose = () =>
104
+ {
105
+ cleanup();
106
+ safeResolve();
107
+ };
108
+
109
+ const onWsError = () =>
110
+ {
111
+ cleanup();
112
+ safeResolve();
113
+ };
114
+
115
+ ws.on('close', onWsClose);
116
+ ws.on('error', onWsError);
117
+
72
118
  const onData = (chunk) =>
73
119
  {
74
120
  if (ws.readyState !== WebSocket.OPEN) { cleanup(); return; }
@@ -99,44 +145,63 @@ module.exports = {
99
145
  ws.send(JSON.stringify(response), (err) =>
100
146
  {
101
147
  cleanup();
102
- return err ? reject(err) : resolve();
148
+ return err ? safeReject(err) : safeResolve();
103
149
  });
104
150
  }
105
151
  else
106
152
  {
107
153
  cleanup();
108
- return resolve();
154
+ safeResolve();
109
155
  }
110
156
  };
111
157
 
112
158
  const onErr = (e) =>
113
159
  {
114
160
  cleanup();
115
- return reject(e);
161
+ safeReject(e);
116
162
  };
117
163
 
164
+ const onClose = () =>
165
+ {
166
+ cleanup();
167
+ safeResolve()
168
+ }
169
+
118
170
  stream.on('data', onData);
119
171
  stream.on('end', onEnd);
120
172
  stream.on('error', onErr);
173
+ stream.on('close', onClose);
121
174
 
122
175
  // Important: stop here; promise resolves on 'end' or rejects on 'error'
123
176
  return;
124
177
  }
125
178
  else
126
179
  {
127
- ws.send(JSON.stringify(message), (err) => err ? reject(err) : resolve());
180
+ ws.send(JSON.stringify(message), (err) => err ? safeReject(err) : safeResolve());
128
181
  }
129
182
  }
130
183
  catch(e)
131
184
  {
132
- reject(e)
185
+ safeReject(e)
133
186
  }
134
187
  });
135
188
  },
136
189
 
137
190
  toRequest: async function(ws, incomingMessage)
138
- {
139
- let data = incomingMessage ? JSON.parse(incomingMessage) : {};
191
+ {
192
+ let data = {};
193
+
194
+ if(incomingMessage)
195
+ {
196
+ try
197
+ {
198
+ data = JSON.parse(incomingMessage);
199
+ }
200
+ catch(e)
201
+ {
202
+ throw new SyntaxError(`Invalid JSON payload: '${incomingMessage}'`);
203
+ }
204
+ }
140
205
 
141
206
  let request = new Configurapi.Request();
142
207