@hyperwatch/hyperwatch 4.3.1 → 5.0.1

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.
Files changed (45) hide show
  1. package/README.md +9 -4
  2. package/config/apache_syslog_example.js +1 -1
  3. package/config/default.js +1 -1
  4. package/config/example.js +4 -4
  5. package/config/express_websocket_example.js +1 -1
  6. package/config/websocket_client_example.js +52 -0
  7. package/docs/configuration.md +67 -6
  8. package/docs/express-embedding.md +137 -0
  9. package/docs/input.md +50 -24
  10. package/docs/tutorials/apache_input.md +16 -14
  11. package/docs/tutorials/express_input.md +10 -8
  12. package/package.json +16 -14
  13. package/scripts/fetch-anthropic-ips.js +60 -0
  14. package/src/app/api.js +129 -3
  15. package/src/app/index.js +8 -4
  16. package/src/app/mount.js +115 -0
  17. package/src/app/websocket.js +12 -9
  18. package/src/app/ws-server.js +123 -0
  19. package/src/constants.js +8 -5
  20. package/src/data/amazon-searchbot-ips.json +304 -0
  21. package/src/data/amazonbot-ips.json +775 -1
  22. package/src/data/chatgpt-user-ips.json +115 -112
  23. package/src/data/claude-bot-ips.json +28 -0
  24. package/src/data/cloudfront-ips.json +14 -0
  25. package/src/data/gptbot-ips.json +0 -3
  26. package/src/data/openai-searchbot-ips.json +4 -0
  27. package/src/index.js +6 -1
  28. package/src/input/http.js +4 -0
  29. package/src/input/syslog.js +5 -1
  30. package/src/input/websocket.js +57 -21
  31. package/src/lib/aggregator.js +112 -19
  32. package/src/lib/formatter.js +10 -1
  33. package/src/lib/log-buffer.js +45 -0
  34. package/src/lib/persistence.js +11 -1
  35. package/src/lib/pipeline.js +140 -14
  36. package/src/lib/recent-map.js +23 -0
  37. package/src/modules/address.js +59 -2
  38. package/src/modules/dnsbl.js +11 -1
  39. package/src/modules/history.js +4 -28
  40. package/src/modules/hostname.js +12 -4
  41. package/src/modules/identity.js +92 -9
  42. package/src/modules/index.js +14 -5
  43. package/src/modules/signature.js +49 -14
  44. package/src/modules/sparkline.js +7 -3
  45. package/src/modules/status.js +6 -1
@@ -10,10 +10,10 @@ Let's start!
10
10
 
11
11
  On the same server where the Node/Express application is running, or on a server that is reachable by it, install the Hyperwatch processor.
12
12
 
13
- As a prerequirement, you'll need Node.js >= 7. Use nvm if you're in trouble.
13
+ As a prerequisite, you'll need Node.js >= 24. We recommend [nvm](https://github.com/nvm-sh/nvm).
14
14
 
15
15
  ```bash
16
- nvm install node
16
+ nvm install 24
17
17
  ```
18
18
 
19
19
  #### Install from npm
@@ -24,7 +24,7 @@ npm install -g @hyperwatch/hyperwatch
24
24
 
25
25
  #### Install from Git
26
26
 
27
- Alternatively, for developement purpose, you can use Git and clone the public repository:
27
+ Alternatively, for development purpose, you can use Git and clone the public repository:
28
28
 
29
29
  ```bash
30
30
  git clone https://github.com/hyperwatch/hyperwatch.git
@@ -38,7 +38,7 @@ In our suggested configuration, Hyperwatch will be listening for access logs usi
38
38
 
39
39
  All communications between your Node/Express application and Hyperwatch will be happening in clear, please only use that setup on your internal network. If on the public internet, we're advising to use the Websocket Secure protocol (wss) which is straightforward but out of the scope of this tutorial.
40
40
 
41
- Now, you can create your own configuration in `express_websocket_example.js`:
41
+ Now, you can create your own configuration in `express_websocket_example.js` (a complete version is available in [`config/express_websocket_example.js`](../../config/express_websocket_example.js)):
42
42
 
43
43
  ```javascript
44
44
  module.exports = function (hyperwatch) {
@@ -77,10 +77,10 @@ app.use(hyperwatchExpressLogger('websocket', 'ws://localhost:3000/input/log'));
77
77
 
78
78
  In this example, there are 3 important things:
79
79
 
80
- 1. If Hyperwatch is running on the same server, we can use `localhost` as IP address.
81
- If it's on a different server, replace `localhost` by the proper private or public IP address.
80
+ 1. If Hyperwatch is running on the same server, we can use `localhost` as IP address.
81
+ If it's on a different server, replace `localhost` by the proper private or public IP address.
82
82
  2. Replace the port (here `3000`) by the relevant one, it should be the main port where Hyperwatch is running.
83
- 3. Finally, the path `/input/log` should match the one configured on Hyperwatch side, If you're following this tutorial from start to begin, nothing to change!
83
+ 3. Finally, the path `/input/log` should match the one configured on Hyperwatch side, If you're following this tutorial from start to end, nothing to change!
84
84
 
85
85
  Now, that you added and configured the Hyperwatch middleware, you can deploy and restart your application.
86
86
 
@@ -96,4 +96,6 @@ hyperwatch express_websocket_example.js
96
96
 
97
97
  ### Browse the interface
98
98
 
99
- Now, you can point your browser to the IP/port where Hyperwatch is running. If you see data flowing, congrats you made it!
99
+ Now, you can point your browser to the `/status` page on the IP/port where Hyperwatch is running (e.g. `http://localhost:3000/status`). If you see traffic going through your input, congrats you made it!
100
+
101
+ To watch the logs live at `/logs/main` and explore aggregations such as `/addresses` or `/identities`, activate the corresponding modules. See [Global Configuration](../configuration.md#modules).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperwatch/hyperwatch",
3
- "version": "4.3.1",
3
+ "version": "5.0.1",
4
4
  "description": "Open Source HTTP Traffic Manager",
5
5
  "license": "Apache-2.0",
6
6
  "author": "François Hodierne <francois@hodierne.net>",
@@ -40,14 +40,13 @@
40
40
  "accept-language-parser": "^1.5.0",
41
41
  "ajv": "^8.20.0",
42
42
  "ajv-formats": "^3.0.1",
43
- "chalk": "^4.1.2",
43
+ "chalk": "^6.0.0",
44
44
  "country-code-emoji": "^2.3.0",
45
45
  "csv-stringify": "^6.8.3",
46
46
  "debug": "^4.4.3",
47
- "dnsbl": "^4.0.3",
48
- "express": "^4.22.2",
49
- "express-ws": "^5.0.2",
50
- "geoip-lite": "^1.4.10",
47
+ "dnsbl": "^5.1.1",
48
+ "express": "^5.2.1",
49
+ "geoip-lite": "^2.0.3",
51
50
  "immutable": "^5.1.9",
52
51
  "ip-cidr": "^4.0.2",
53
52
  "lodash": "^4.18.1",
@@ -55,25 +54,28 @@
55
54
  "micro-strptime": "^1.0.0",
56
55
  "proxy-addr": "^2.0.7",
57
56
  "rc": "^1.2.8",
58
- "syslog-parse": "^1.3.1",
57
+ "syslog-parse": "^2.0.0",
59
58
  "tail": "^2.2.6",
60
59
  "ws": "^8.21.3"
61
60
  },
62
61
  "devDependencies": {
63
- "@eslint/js": "^9.39.5",
62
+ "@eslint/js": "^10.0.1",
64
63
  "depcheck": "^1.4.7",
65
- "eslint": "^9.39.5",
66
- "eslint-plugin-import": "^2.32.0",
67
- "eslint-plugin-n": "^17.24.0",
64
+ "eslint": "^10.9.1",
65
+ "eslint-plugin-import-x": "^4.17.1",
66
+ "eslint-plugin-n": "^18.3.0",
68
67
  "globals": "^17.11.0",
69
68
  "husky": "^9.1.7",
70
- "lint-staged": "^16.4.0",
71
- "mocha": "^11.8.0",
69
+ "lint-staged": "^17.4.1",
70
+ "mocha": "^12.0.0",
72
71
  "prettier": "^3.9.6",
73
72
  "prettier-package-json": "^2.8.0"
74
73
  },
75
74
  "engines": {
76
- "node": ">=20"
75
+ "node": ">=24"
76
+ },
77
+ "allowScripts": {
78
+ "unrs-resolver": false
77
79
  },
78
80
  "depcheck": {
79
81
  "ignores": [
@@ -0,0 +1,60 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const dataDir = path.join(__dirname, '..', 'src', 'data');
5
+
6
+ // Anthropic publishes a single list covering all Claude crawlers
7
+ // (ClaudeBot, Claude-User, Claude-SearchBot).
8
+ const sources = [
9
+ { name: 'claude-bot-ips', url: 'https://claude.com/crawling/bots.json' },
10
+ ];
11
+
12
+ function extractCidrs(data) {
13
+ const prefixes = Array.isArray(data) ? data : data.prefixes;
14
+ if (!Array.isArray(prefixes)) {
15
+ throw new Error('unexpected payload: no prefixes array');
16
+ }
17
+ return prefixes
18
+ .map((prefix) => {
19
+ if (typeof prefix === 'string') {
20
+ return prefix;
21
+ }
22
+ return prefix.ipv4Prefix || prefix.ipv6Prefix || prefix.ip_prefix;
23
+ })
24
+ .filter(Boolean)
25
+ .map((cidr) => {
26
+ if (cidr.includes('/')) {
27
+ return cidr;
28
+ }
29
+ return cidr.includes(':') ? `${cidr}/128` : `${cidr}/32`;
30
+ });
31
+ }
32
+
33
+ async function fetchAndStore({ name, url }) {
34
+ const res = await fetch(url);
35
+ if (!res.ok) {
36
+ console.error(`Failed to fetch ${name}: ${res.status} ${res.statusText}`);
37
+ process.exitCode = 1;
38
+ return;
39
+ }
40
+ const cidrs = extractCidrs(await res.json());
41
+ if (cidrs.length === 0) {
42
+ console.error(`Failed to fetch ${name}: empty list, keeping current file`);
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ const filePath = path.join(dataDir, `${name}.json`);
47
+ fs.writeFileSync(filePath, `${JSON.stringify(cidrs, null, 2)}\n`);
48
+ console.log(`${name}: ${cidrs.length} CIDRs`);
49
+ }
50
+
51
+ async function main() {
52
+ for (const source of sources) {
53
+ await fetchAndStore(source);
54
+ }
55
+ }
56
+
57
+ main().catch((error) => {
58
+ console.error(error);
59
+ process.exitCode = 1;
60
+ });
package/src/app/api.js CHANGED
@@ -7,15 +7,131 @@ const express = require('express');
7
7
 
8
8
  const monitoring = require('../lib/monitoring');
9
9
  const persistence = require('../lib/persistence');
10
+ const pipeline = require('../lib/pipeline');
10
11
  const { formatTable } = require('../lib/util');
11
12
  const stylesheet = require('../stylesheet');
12
13
 
14
+ const wsServer = require('./ws-server');
15
+
13
16
  const script = fs.readFileSync(path.join(__dirname, '..', 'script.js'));
14
17
 
15
18
  const app = express();
16
19
 
20
+ // WebSocket upgrades dispatched by mount() when Hyperwatch is embedded
21
+ app.use(wsServer.middleware);
22
+
17
23
  app.use(express.json());
18
24
 
25
+ function renderHtmlTree(node) {
26
+ const label = [];
27
+ if (node.name) {
28
+ label.push(`<strong>${node.name}</strong>`);
29
+ }
30
+ if (node.op) {
31
+ label.push(`<span class="op">[${node.op}]</span>`);
32
+ }
33
+ if (node.module) {
34
+ label.push(`<span class="module">(${node.module})</span>`);
35
+ }
36
+ if (node.fnName) {
37
+ label.push(`<span class="fn">${node.fnName}</span>`);
38
+ }
39
+ if (node.label) {
40
+ label.push(`<span class="label">${node.label}</span>`);
41
+ }
42
+
43
+ let html = `<li>${label.join(' ')}`;
44
+ if (node.children && node.children.length > 0) {
45
+ html += '<ul>';
46
+ for (const child of node.children) {
47
+ html += renderHtmlTree(child);
48
+ }
49
+ html += '</ul>';
50
+ }
51
+ html += '</li>';
52
+ return html;
53
+ }
54
+
55
+ function renderHtmlInputs(inputs) {
56
+ if (!inputs || inputs.length === 0) {
57
+ return '';
58
+ }
59
+ let html = '<ul>';
60
+ for (const input of inputs) {
61
+ html += `<li><strong>${input.name}</strong> <span class="op">[input]</span>`;
62
+ if (input.status) {
63
+ html += ` <span class="module">(${input.status})</span>`;
64
+ }
65
+ html += ` accepted: ${input.accepted}, rejected: ${input.rejected}`;
66
+ if (input.tree) {
67
+ html += `<ul>${renderHtmlTree(input.tree)}</ul>`;
68
+ }
69
+ html += '</li>';
70
+ }
71
+ html += '</ul>';
72
+ return html;
73
+ }
74
+
75
+ app.get('/nodes{.:format}', (req, res) => {
76
+ const nodes = Object.keys(pipeline.nodes);
77
+ const format = req.params.format;
78
+ const view = req.query.view;
79
+
80
+ if (format && !['csv', 'json'].includes(format)) {
81
+ res.sendStatus(404);
82
+ return;
83
+ }
84
+
85
+ if (format === 'csv') {
86
+ const csv = stringify(
87
+ nodes.map((name) => ({ name })),
88
+ {
89
+ header: true,
90
+ columns: ['name'],
91
+ }
92
+ );
93
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
94
+ res.setHeader('Content-Disposition', 'attachment; filename="nodes.csv"');
95
+ res.send(csv);
96
+ } else if (format === 'json') {
97
+ if (view === 'tree') {
98
+ res.json(pipeline.getTree());
99
+ } else {
100
+ res.json(nodes);
101
+ }
102
+ } else {
103
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
104
+ if (view === 'tree') {
105
+ const tree = pipeline.getTree();
106
+ res.send(
107
+ `<!DOCTYPE html>
108
+ <html>
109
+ <head>
110
+ <style>${stylesheet}
111
+ .op { color: #666; }
112
+ .module { color: #0a0; }
113
+ .fn { color: #00a; }
114
+ .label { color: #a50; }
115
+ ul { list-style: none; padding-left: 1.5em; }
116
+ </style>
117
+ </head>
118
+ <body>${renderHtmlInputs(tree.inputs)}<ul>${renderHtmlTree(tree)}</ul></body>
119
+ </html>`
120
+ );
121
+ } else {
122
+ res.send(
123
+ `<!DOCTYPE html>
124
+ <html>
125
+ <head>
126
+ <style>${stylesheet}</style>
127
+ </head>
128
+ <body>${formatTable(nodes.map((name) => ({ name })))}</body>
129
+ </html>`
130
+ );
131
+ }
132
+ }
133
+ });
134
+
19
135
  app.streamToHttp = (
20
136
  endpoint,
21
137
  stream,
@@ -85,17 +201,22 @@ body { display: flex; flex-direction: column-reverse; }
85
201
  res.write(`<div>${line}</div>\n`);
86
202
  }
87
203
  });
88
- });
204
+ }, `http:${endpoint}`);
89
205
  };
90
206
 
91
207
  app.registerAggregator = (name, aggregator) => {
92
208
  persistence.register(name, aggregator);
93
- app.get(`/${name}.:format(json|csv)?`, (req, res) => {
209
+ app.get(`/${name}{.:format}`, (req, res) => {
94
210
  const raw = req.query.raw ? true : false;
95
211
  const format = req.params.format || (raw ? 'json' : null);
96
212
  const limit = req.query.limit || 100;
97
213
  const sort = req.query.sort || 'count15m';
98
214
 
215
+ if (format && !['csv', 'json'].includes(format)) {
216
+ res.sendStatus(404);
217
+ return;
218
+ }
219
+
99
220
  const data = aggregator.getData({
100
221
  sort,
101
222
  limit,
@@ -132,7 +253,12 @@ app.registerAggregator = (name, aggregator) => {
132
253
  }
133
254
  });
134
255
 
135
- app.get(`/${name}/:identifier.:format(json)?`, (req, res) => {
256
+ app.delete(`/${name}`, (req, res) => {
257
+ aggregator.reset();
258
+ res.send({ success: true });
259
+ });
260
+
261
+ app.get(`/${name}/:identifier{.json}`, (req, res) => {
136
262
  const entry = aggregator.get(req.params.identifier);
137
263
  if (!entry) {
138
264
  res.status(404).send('Not Found');
package/src/app/index.js CHANGED
@@ -1,18 +1,22 @@
1
1
  const http = require('http');
2
2
 
3
3
  const express = require('express');
4
- const expressWs = require('express-ws');
5
4
 
6
5
  const constants = require('../constants');
7
6
 
8
7
  const api = require('./api');
8
+ const mount = require('./mount');
9
9
  const websocket = require('./websocket');
10
+ const wsServer = require('./ws-server');
10
11
 
11
12
  const app = express();
12
13
  const httpServer = http.createServer(app);
13
- expressWs(app, httpServer);
14
14
 
15
- app.use(api, websocket);
15
+ httpServer.on('upgrade', (request, socket, head) => {
16
+ wsServer.handleUpgrade(request, socket, head);
17
+ });
18
+
19
+ app.use(api);
16
20
 
17
21
  function start() {
18
22
  const port = process.env.PORT || constants.port;
@@ -25,4 +29,4 @@ function stop() {
25
29
  httpServer.close();
26
30
  }
27
31
 
28
- module.exports = { api, start, stop, websocket };
32
+ module.exports = { api, mount, start, stop, websocket };
@@ -0,0 +1,115 @@
1
+ const api = require('./api');
2
+ const wsServer = require('./ws-server');
3
+
4
+ // Servers where Hyperwatch handles WebSocket upgrades, with their listener
5
+ const upgradeListeners = new WeakMap();
6
+
7
+ // Paths where Hyperwatch is mounted, per app. Express can't remove routes, so
8
+ // a path stays mounted for the life of the app.
9
+ const mountedPaths = new WeakMap();
10
+
11
+ // A path as the app routes it: case-insensitive unless the app says otherwise
12
+ function routingKey(app, path) {
13
+ return app.enabled('case sensitive routing') ? path : path.toLowerCase();
14
+ }
15
+
16
+ function validate(app, { server, path, middleware, fallback }) {
17
+ if (typeof app !== 'function' || typeof app.use !== 'function') {
18
+ throw new TypeError('mount() expects an Express app');
19
+ }
20
+ if (!server || typeof server.on !== 'function') {
21
+ throw new TypeError(
22
+ 'mount() expects the HTTP server of the app: { server }'
23
+ );
24
+ }
25
+ if (typeof path !== 'string' || !path.startsWith('/') || path.endsWith('/')) {
26
+ throw new TypeError(
27
+ `Invalid mount path "${path}": it must start with "/" and not end with "/"`
28
+ );
29
+ }
30
+ const middlewares = middleware === undefined ? [] : [].concat(middleware);
31
+ if (!middlewares.every((fn) => typeof fn === 'function')) {
32
+ throw new TypeError(
33
+ 'middleware must be a function or an array of functions'
34
+ );
35
+ }
36
+ if (fallback !== undefined && typeof fallback !== 'function') {
37
+ throw new TypeError('fallback must be a function');
38
+ }
39
+ const paths = mountedPaths.get(app);
40
+ if (paths && paths.has(routingKey(app, path))) {
41
+ throw new Error(
42
+ `Hyperwatch is already mounted on this app at ${path}: its routes can't be removed, so mounting again can't change its options (e.g. middleware)`
43
+ );
44
+ }
45
+ if (upgradeListeners.has(server)) {
46
+ throw new Error('Hyperwatch is already mounted on this server');
47
+ }
48
+ return middlewares;
49
+ }
50
+
51
+ // Whether a request is under the mount path, matching case like Express
52
+ function isUnderPath(target, path, caseSensitive) {
53
+ const pathname = caseSensitive
54
+ ? target.pathname
55
+ : target.pathname.toLowerCase();
56
+ const mountPath = caseSensitive ? path : path.toLowerCase();
57
+ return pathname === mountPath || pathname.startsWith(`${mountPath}/`);
58
+ }
59
+
60
+ /**
61
+ * Mount Hyperwatch in an Express app, under an explicit path:
62
+ *
63
+ * hyperwatch.app.mount(app, { server, path: '/_hyperwatch', middleware: auth });
64
+ *
65
+ * - Registers `app.use(path, ...middleware, router)` where it's called, so the
66
+ * app's middleware order is kept.
67
+ * - Adds one 'upgrade' listener to `server`. WebSocket upgrades under `path`
68
+ * go through the app like HTTP requests, so `middleware` applies to both.
69
+ * Other upgrades go to `fallback` when given, or are left to the server's
70
+ * other listeners. Malformed targets get 400.
71
+ * - Never creates a server nor listens.
72
+ *
73
+ * Mounting again on the same server, or on the same app at the same path
74
+ * (even after detachUpgrades()), throws before registering anything.
75
+ * Returns `{ path, detachUpgrades }`: detachUpgrades() removes the upgrade
76
+ * listener and releases the server. Express can't remove routes, so the
77
+ * HTTP routes stay mounted on the app, with their original middleware.
78
+ */
79
+ function mount(app, options = {}) {
80
+ const middlewares = validate(app, options);
81
+ const { server, path, fallback } = options;
82
+
83
+ app.use(path, ...middlewares, api);
84
+ if (!mountedPaths.has(app)) {
85
+ mountedPaths.set(app, new Set());
86
+ }
87
+ mountedPaths.get(app).add(routingKey(app, path));
88
+
89
+ const listener = (req, socket, head) => {
90
+ const target = wsServer.parseTarget(req.url);
91
+ // Malformed targets are rejected before reaching the app or fallback
92
+ if (!target) {
93
+ return wsServer.reject(socket, 400, 'Bad Request');
94
+ }
95
+ if (isUnderPath(target, path, app.enabled('case sensitive routing'))) {
96
+ wsServer.dispatch(app, req, socket, head);
97
+ } else if (fallback) {
98
+ fallback(req, socket, head);
99
+ }
100
+ };
101
+ server.on('upgrade', listener);
102
+ upgradeListeners.set(server, listener);
103
+
104
+ return {
105
+ path,
106
+ detachUpgrades() {
107
+ if (upgradeListeners.get(server) === listener) {
108
+ server.off('upgrade', listener);
109
+ upgradeListeners.delete(server);
110
+ }
111
+ },
112
+ };
113
+ }
114
+
115
+ module.exports = mount;
@@ -1,15 +1,18 @@
1
1
  const crypto = require('crypto');
2
2
 
3
- const express = require('express');
4
- const expressWs = require('express-ws');
5
-
6
3
  const constants = require('../constants');
7
4
  const monitoring = require('../lib/monitoring');
8
5
 
9
- const app = express();
10
- expressWs(app);
6
+ const wsServer = require('./ws-server');
7
+
8
+ /**
9
+ * Express middleware completing the Hyperwatch WebSocket upgrades dispatched
10
+ * by hyperwatch.app.mount(), which already includes it. Kept for apps that
11
+ * mounted it explicitly: on its own, it doesn't handle any upgrade.
12
+ */
13
+ const websocket = (req, res, next) => wsServer.middleware(req, res, next);
11
14
 
12
- app.streamToWebsocket = (
15
+ websocket.streamToWebsocket = (
13
16
  endpoint,
14
17
  stream,
15
18
  { name = `WebSocket: ${endpoint}`, monitoringEnabled = false } = {}
@@ -37,7 +40,7 @@ app.streamToWebsocket = (
37
40
  };
38
41
  updateMonitoringStatus();
39
42
 
40
- app.ws(endpoint, (client, req) => {
43
+ wsServer.ws(endpoint, (client, req) => {
41
44
  const clientId = req.query.clientId || crypto.randomUUID();
42
45
  if (clients[clientId]) {
43
46
  console.log(`Client '${clientId}' is already connected. Terminating.`);
@@ -70,7 +73,7 @@ app.streamToWebsocket = (
70
73
  client.send(JSON.stringify(log));
71
74
  }
72
75
  });
73
- });
76
+ }, `ws:${endpoint}`);
74
77
 
75
78
  // Heartbeat: detect and clean up stale connections
76
79
  setInterval(() => {
@@ -88,4 +91,4 @@ app.streamToWebsocket = (
88
91
  }, constants.heartbeatInterval || 30000);
89
92
  };
90
93
 
91
- module.exports = app;
94
+ module.exports = websocket;
@@ -0,0 +1,123 @@
1
+ const http = require('http');
2
+
3
+ const { WebSocketServer } = require('ws');
4
+
5
+ const wss = new WebSocketServer({ noServer: true });
6
+
7
+ const routes = new Map();
8
+
9
+ // Marks an upgrade request dispatched through an Express app
10
+ const upgradeKey = Symbol('hyperwatch.upgrade');
11
+
12
+ function ws(path, handler) {
13
+ routes.set(path, handler);
14
+ }
15
+
16
+ /**
17
+ * Parse the target of an upgrade request without throwing. WebSocket
18
+ * upgrades use the origin form ("/path?query"): anything else is malformed.
19
+ * `pathname` is kept as sent, like Express routing does; `urlPathname` is
20
+ * normalized by the URL parser.
21
+ */
22
+ function parseTarget(url) {
23
+ if (typeof url !== 'string' || !url.startsWith('/')) {
24
+ return null;
25
+ }
26
+ try {
27
+ const { pathname: urlPathname, searchParams } = new URL(
28
+ url,
29
+ 'http://localhost'
30
+ );
31
+ return {
32
+ pathname: url.split(/[?#]/)[0],
33
+ urlPathname,
34
+ query: Object.fromEntries(searchParams),
35
+ };
36
+ } catch (err) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function reject(socket, status, message) {
42
+ socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
43
+ }
44
+
45
+ // Find the handler of a route, matching case like Express routing does
46
+ function findRoute(pathname, caseSensitive) {
47
+ if (caseSensitive) {
48
+ return routes.get(pathname);
49
+ }
50
+ const lowerCase = pathname.toLowerCase();
51
+ for (const [route, handler] of routes) {
52
+ if (route.toLowerCase() === lowerCase) {
53
+ return handler;
54
+ }
55
+ }
56
+ }
57
+
58
+ function handleUpgrade(request, socket, head) {
59
+ const target = parseTarget(request.url);
60
+ if (!target) {
61
+ return reject(socket, 400, 'Bad Request');
62
+ }
63
+ request.query = target.query;
64
+
65
+ // Standalone mode: exact match, stream names are case-sensitive
66
+ const handler = routes.get(target.urlPathname);
67
+ if (handler) {
68
+ wss.handleUpgrade(request, socket, head, (client) => {
69
+ handler(client, request);
70
+ });
71
+ } else {
72
+ socket.destroy();
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Express middleware completing the WebSocket upgrades sent by dispatch().
78
+ * The route is matched on the path relative to where the middleware is
79
+ * mounted, so `app.use('/_hyperwatch', middleware)` serves `/_hyperwatch/logs/raw`.
80
+ */
81
+ function middleware(req, res, next) {
82
+ const upgrade = req[upgradeKey];
83
+ if (!upgrade) {
84
+ return next();
85
+ }
86
+ const handler = findRoute(
87
+ req.path,
88
+ req.app.enabled('case sensitive routing')
89
+ );
90
+ if (!handler) {
91
+ // The mount path is Hyperwatch's: unknown routes end here
92
+ return res.status(404).end();
93
+ }
94
+ req[upgradeKey] = null;
95
+ res.detachSocket(upgrade.socket);
96
+ wss.handleUpgrade(req, upgrade.socket, upgrade.head, (client) => {
97
+ handler(client, req);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Send a WebSocket upgrade through an Express app, like an HTTP request: it
103
+ * goes through the app's middlewares (e.g. authentication) and reaches the
104
+ * WebSocket middleware where it's mounted. The response is bound to the
105
+ * socket, so a middleware can reject the upgrade with an HTTP response, and
106
+ * Express answers errors and unknown routes as usual (e.g. 401, 503, 404).
107
+ */
108
+ function dispatch(app, req, socket, head) {
109
+ req[upgradeKey] = { socket, head };
110
+ const res = new http.ServerResponse(req);
111
+ res.assignSocket(socket);
112
+ res.on('finish', () => socket.end());
113
+ app(req, res);
114
+ }
115
+
116
+ module.exports = {
117
+ ws,
118
+ handleUpgrade,
119
+ middleware,
120
+ dispatch,
121
+ parseTarget,
122
+ reject,
123
+ };