@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.
- package/README.md +9 -4
- package/config/apache_syslog_example.js +1 -1
- package/config/default.js +1 -1
- package/config/example.js +4 -4
- package/config/express_websocket_example.js +1 -1
- package/config/websocket_client_example.js +52 -0
- package/docs/configuration.md +67 -6
- package/docs/express-embedding.md +137 -0
- package/docs/input.md +50 -24
- package/docs/tutorials/apache_input.md +16 -14
- package/docs/tutorials/express_input.md +10 -8
- package/package.json +16 -14
- package/scripts/fetch-anthropic-ips.js +60 -0
- package/src/app/api.js +129 -3
- package/src/app/index.js +8 -4
- package/src/app/mount.js +115 -0
- package/src/app/websocket.js +12 -9
- package/src/app/ws-server.js +123 -0
- package/src/constants.js +8 -5
- package/src/data/amazon-searchbot-ips.json +304 -0
- package/src/data/amazonbot-ips.json +775 -1
- package/src/data/chatgpt-user-ips.json +115 -112
- package/src/data/claude-bot-ips.json +28 -0
- package/src/data/cloudfront-ips.json +14 -0
- package/src/data/gptbot-ips.json +0 -3
- package/src/data/openai-searchbot-ips.json +4 -0
- package/src/index.js +6 -1
- package/src/input/http.js +4 -0
- package/src/input/syslog.js +5 -1
- package/src/input/websocket.js +57 -21
- package/src/lib/aggregator.js +112 -19
- package/src/lib/formatter.js +10 -1
- package/src/lib/log-buffer.js +45 -0
- package/src/lib/persistence.js +11 -1
- package/src/lib/pipeline.js +140 -14
- package/src/lib/recent-map.js +23 -0
- package/src/modules/address.js +59 -2
- package/src/modules/dnsbl.js +11 -1
- package/src/modules/history.js +4 -28
- package/src/modules/hostname.js +12 -4
- package/src/modules/identity.js +92 -9
- package/src/modules/index.js +14 -5
- package/src/modules/signature.js +49 -14
- package/src/modules/sparkline.js +7 -3
- package/src/modules/status.js +6 -1
package/src/input/websocket.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { fromJS } = require('immutable');
|
|
2
2
|
const WebSocket = require('ws');
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const wsServer = require('../app/ws-server');
|
|
5
5
|
|
|
6
6
|
const defaultParse = (s) => fromJS(JSON.parse(s));
|
|
7
7
|
|
|
@@ -19,12 +19,20 @@ function create({
|
|
|
19
19
|
heartbeatInterval = 30000,
|
|
20
20
|
}) {
|
|
21
21
|
let client;
|
|
22
|
+
let keepAlive;
|
|
23
|
+
let reconnectTimer;
|
|
24
|
+
// Each connection belongs to a generation. stop() and every new connection
|
|
25
|
+
// move to the next one, so events from an obsolete socket (a late close
|
|
26
|
+
// after a restart, its heartbeat) can't reconnect or touch the current one.
|
|
27
|
+
let generation = 0;
|
|
22
28
|
|
|
23
29
|
let reconnectAttempts = 0;
|
|
24
30
|
|
|
25
31
|
const setupWebSocketClient = ({ status, success, reject }) => {
|
|
32
|
+
const current = ++generation;
|
|
33
|
+
const isCurrent = () => current === generation;
|
|
26
34
|
let isAlive;
|
|
27
|
-
let
|
|
35
|
+
let heartbeat;
|
|
28
36
|
|
|
29
37
|
if (username && password) {
|
|
30
38
|
options.headers = options.headers || {};
|
|
@@ -33,32 +41,37 @@ function create({
|
|
|
33
41
|
).toString('base64')}`;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
const socket = new WebSocket(address, [], options);
|
|
45
|
+
client = socket;
|
|
37
46
|
status(null, `Waiting for connection to ${address}`);
|
|
38
47
|
|
|
39
|
-
|
|
48
|
+
socket.on('open', () => {
|
|
49
|
+
if (!isCurrent()) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
40
52
|
isAlive = true;
|
|
41
53
|
reconnectAttempts = 0;
|
|
42
54
|
status(null, `Listening to ${address}`);
|
|
43
55
|
|
|
44
56
|
// Heartbeat: detect stale connections
|
|
45
|
-
|
|
57
|
+
heartbeat = setInterval(() => {
|
|
46
58
|
if (isAlive === false) {
|
|
47
|
-
|
|
48
|
-
clearInterval(
|
|
59
|
+
socket.terminate();
|
|
60
|
+
clearInterval(heartbeat);
|
|
49
61
|
} else {
|
|
50
62
|
try {
|
|
51
|
-
|
|
63
|
+
socket.ping();
|
|
52
64
|
} catch (err) {
|
|
53
65
|
status(err, 'Websocket error');
|
|
54
66
|
}
|
|
55
67
|
isAlive = false;
|
|
56
68
|
}
|
|
57
69
|
}, heartbeatInterval);
|
|
70
|
+
keepAlive = heartbeat;
|
|
58
71
|
});
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
if (sample !== 1 && Math.random() > sample) {
|
|
73
|
+
socket.on('message', (message) => {
|
|
74
|
+
if (!isCurrent() || (sample !== 1 && Math.random() > sample)) {
|
|
62
75
|
return;
|
|
63
76
|
}
|
|
64
77
|
try {
|
|
@@ -68,19 +81,22 @@ function create({
|
|
|
68
81
|
}
|
|
69
82
|
});
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
socket.on('error', (err) => {
|
|
85
|
+
if (isCurrent()) {
|
|
86
|
+
status(err, 'Websocket error');
|
|
87
|
+
}
|
|
73
88
|
});
|
|
74
89
|
|
|
75
|
-
|
|
90
|
+
socket.on('pong', () => {
|
|
76
91
|
isAlive = true;
|
|
77
92
|
});
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
94
|
+
socket.on('close', () => {
|
|
95
|
+
clearInterval(heartbeat);
|
|
96
|
+
if (!isCurrent()) {
|
|
97
|
+
return;
|
|
83
98
|
}
|
|
99
|
+
status(null, 'Websocket connection has been closed');
|
|
84
100
|
if (reconnectOnClose) {
|
|
85
101
|
reconnectAttempts++;
|
|
86
102
|
const delay = Math.min(
|
|
@@ -91,7 +107,7 @@ function create({
|
|
|
91
107
|
null,
|
|
92
108
|
`Reconnecting Websocket in ${delay / 1000}s (attempt ${reconnectAttempts})`
|
|
93
109
|
);
|
|
94
|
-
setTimeout(() => {
|
|
110
|
+
reconnectTimer = setTimeout(() => {
|
|
95
111
|
setupWebSocketClient({ status, success, reject });
|
|
96
112
|
}, delay);
|
|
97
113
|
}
|
|
@@ -99,7 +115,7 @@ function create({
|
|
|
99
115
|
};
|
|
100
116
|
|
|
101
117
|
const setupWebSocketServer = ({ status, success, reject }) => {
|
|
102
|
-
|
|
118
|
+
wsServer.ws(path, (ws) => {
|
|
103
119
|
ws.on('message', (message) => {
|
|
104
120
|
if (sample !== 1 && Math.random() > sample) {
|
|
105
121
|
return;
|
|
@@ -117,6 +133,7 @@ function create({
|
|
|
117
133
|
return {
|
|
118
134
|
name: `${name} ${type}`,
|
|
119
135
|
start: ({ success, reject, status, log }) => {
|
|
136
|
+
reconnectAttempts = 0;
|
|
120
137
|
if (type === 'client') {
|
|
121
138
|
setupWebSocketClient({ status, success, reject });
|
|
122
139
|
} else if (type === 'server') {
|
|
@@ -126,9 +143,28 @@ function create({
|
|
|
126
143
|
log(new Error(errMsg), 'error');
|
|
127
144
|
}
|
|
128
145
|
},
|
|
146
|
+
// Never throws: a failing input must not prevent the others from stopping,
|
|
147
|
+
// nor Hyperwatch from persisting its data on shutdown.
|
|
129
148
|
stop: () => {
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
// Obsoletes the current connection: its close won't reconnect
|
|
150
|
+
generation++;
|
|
151
|
+
clearTimeout(reconnectTimer);
|
|
152
|
+
clearInterval(keepAlive);
|
|
153
|
+
if (!client) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
if (client.readyState === WebSocket.CONNECTING) {
|
|
158
|
+
// close() throws while the connection is not established yet
|
|
159
|
+
client.terminate();
|
|
160
|
+
} else if (client.readyState === WebSocket.OPEN) {
|
|
161
|
+
client.close();
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(
|
|
165
|
+
`${name}: error while closing the Websocket:`,
|
|
166
|
+
err.message
|
|
167
|
+
);
|
|
132
168
|
}
|
|
133
169
|
},
|
|
134
170
|
};
|
package/src/lib/aggregator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { Map, Set, fromJS, is } = require('immutable');
|
|
1
|
+
const { List, Map, Set, fromJS, is } = require('immutable');
|
|
2
2
|
|
|
3
3
|
const { Formatter, address, identity } = require('../lib/formatter');
|
|
4
4
|
const { Speed } = require('../lib/speed');
|
|
@@ -9,17 +9,35 @@ const {
|
|
|
9
9
|
md5,
|
|
10
10
|
} = require('../lib/util');
|
|
11
11
|
|
|
12
|
+
const lastSeen = (entry) => {
|
|
13
|
+
const ts =
|
|
14
|
+
entry.getIn(['speed', 'per_minute']) &&
|
|
15
|
+
entry.getIn(['speed', 'per_minute']).latest;
|
|
16
|
+
return ts ? new Date(ts * 1000).toISOString() : '';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const statusCount = (key) => (entry) =>
|
|
20
|
+
entry.getIn(['speed', key]) ? aggregateCount(entry, key) : 0;
|
|
21
|
+
|
|
12
22
|
const defaultFormatter = new Formatter();
|
|
13
23
|
defaultFormatter.setFormats([
|
|
14
24
|
['identity', identity],
|
|
15
25
|
|
|
16
|
-
['address', address],
|
|
26
|
+
['address', (entry) => entry.getIn(['address', 'value']) || ''],
|
|
27
|
+
['hostname', address],
|
|
17
28
|
|
|
18
29
|
['count15m', (entry) => aggregateCount(entry, 'per_minute')],
|
|
19
30
|
['count24h', (entry) => aggregateCount(entry, 'per_hour')],
|
|
20
31
|
|
|
32
|
+
['2xx15m', statusCount('2xx_per_minute')],
|
|
33
|
+
['2xx24h', statusCount('2xx_per_hour')],
|
|
34
|
+
['4xx15m', statusCount('4xx_per_minute')],
|
|
35
|
+
['4xx24h', statusCount('4xx_per_hour')],
|
|
36
|
+
|
|
21
37
|
['execTime15m', (entry) => formatDuration(aggregateSum(entry, 'per_minute'))],
|
|
22
38
|
['execTime24h', (entry) => formatDuration(aggregateSum(entry, 'per_hour'))],
|
|
39
|
+
|
|
40
|
+
['lastSeen', lastSeen],
|
|
23
41
|
]);
|
|
24
42
|
|
|
25
43
|
const defaultEnricher = (entry, log) => {
|
|
@@ -52,6 +70,10 @@ const defaultIdentifier = (log) => {
|
|
|
52
70
|
const defaultSorters = {
|
|
53
71
|
count15m: (entry) => aggregateCount(entry, 'per_minute'),
|
|
54
72
|
count24h: (entry) => aggregateCount(entry, 'per_hour'),
|
|
73
|
+
'2xx15m': statusCount('2xx_per_minute'),
|
|
74
|
+
'2xx24h': statusCount('2xx_per_hour'),
|
|
75
|
+
'4xx15m': statusCount('4xx_per_minute'),
|
|
76
|
+
'4xx24h': statusCount('4xx_per_hour'),
|
|
55
77
|
latest: (entry) => entry.getIn(['speed', 'per_minute']).latest,
|
|
56
78
|
execTime15m: (entry) => aggregateSum(entry, 'per_minute'),
|
|
57
79
|
execTime24h: (entry) => aggregateSum(entry, 'per_hour'),
|
|
@@ -60,10 +82,14 @@ const defaultSorters = {
|
|
|
60
82
|
class Aggregator {
|
|
61
83
|
constructor() {
|
|
62
84
|
this.entries = new Map();
|
|
63
|
-
|
|
85
|
+
// Snapshot the default formats: modules extend defaultFormatter during
|
|
86
|
+
// init, aggregators are created during start, and per-aggregator
|
|
87
|
+
// insertFormat calls must not leak into other aggregators.
|
|
88
|
+
this.formatter = defaultFormatter.clone();
|
|
64
89
|
this.enricher = defaultEnricher;
|
|
65
90
|
this.identifier = defaultIdentifier;
|
|
66
|
-
this.sorters = defaultSorters;
|
|
91
|
+
this.sorters = { ...defaultSorters };
|
|
92
|
+
this.entryGc = null;
|
|
67
93
|
this.gcSize = 1000;
|
|
68
94
|
|
|
69
95
|
setInterval(() => this.gc(), 60 * 1000).unref();
|
|
@@ -81,6 +107,13 @@ class Aggregator {
|
|
|
81
107
|
return this;
|
|
82
108
|
}
|
|
83
109
|
|
|
110
|
+
// Per-entry cleanup run on every gc pass, regardless of aggregator size
|
|
111
|
+
setEntryGc(fn) {
|
|
112
|
+
this.entryGc = fn;
|
|
113
|
+
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
84
117
|
setIdentifier(fn) {
|
|
85
118
|
this.identifier = fn;
|
|
86
119
|
|
|
@@ -93,6 +126,8 @@ class Aggregator {
|
|
|
93
126
|
const rawExecTime = Number(log.get('executionTime'));
|
|
94
127
|
const executionTime = Number.isFinite(rawExecTime) ? rawExecTime : 0;
|
|
95
128
|
|
|
129
|
+
const status = log.getIn(['response', 'status']);
|
|
130
|
+
|
|
96
131
|
if (!this.entries.has(id)) {
|
|
97
132
|
this.entries = this.entries
|
|
98
133
|
.setIn([id, 'id'], id)
|
|
@@ -104,7 +139,11 @@ class Aggregator {
|
|
|
104
139
|
.setIn(
|
|
105
140
|
[id, 'speed', 'per_hour'],
|
|
106
141
|
new Speed(3600, 24).hit(undefined, executionTime)
|
|
107
|
-
)
|
|
142
|
+
)
|
|
143
|
+
.setIn([id, 'speed', '2xx_per_minute'], new Speed(60, 15))
|
|
144
|
+
.setIn([id, 'speed', '2xx_per_hour'], new Speed(3600, 24))
|
|
145
|
+
.setIn([id, 'speed', '4xx_per_minute'], new Speed(60, 15))
|
|
146
|
+
.setIn([id, 'speed', '4xx_per_hour'], new Speed(3600, 24));
|
|
108
147
|
} else {
|
|
109
148
|
this.entries = this.entries
|
|
110
149
|
.updateIn([id, 'speed', 'per_minute'], (speed) =>
|
|
@@ -115,6 +154,16 @@ class Aggregator {
|
|
|
115
154
|
);
|
|
116
155
|
}
|
|
117
156
|
|
|
157
|
+
if (status >= 200 && status < 300) {
|
|
158
|
+
this.entries = this.entries
|
|
159
|
+
.updateIn([id, 'speed', '2xx_per_minute'], (speed) => speed.hit())
|
|
160
|
+
.updateIn([id, 'speed', '2xx_per_hour'], (speed) => speed.hit());
|
|
161
|
+
} else if (status >= 400 && status < 500) {
|
|
162
|
+
this.entries = this.entries
|
|
163
|
+
.updateIn([id, 'speed', '4xx_per_minute'], (speed) => speed.hit())
|
|
164
|
+
.updateIn([id, 'speed', '4xx_per_hour'], (speed) => speed.hit());
|
|
165
|
+
}
|
|
166
|
+
|
|
118
167
|
this.entries = this.entries.updateIn([id], (entry) =>
|
|
119
168
|
this.enricher(entry, log)
|
|
120
169
|
);
|
|
@@ -131,10 +180,8 @@ class Aggregator {
|
|
|
131
180
|
sort = 'count15m';
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
.sort()
|
|
137
|
-
.reverse()
|
|
183
|
+
const sorted = this.entries.map(this.sorters[sort]).sort().reverse();
|
|
184
|
+
const rawData = sorted
|
|
138
185
|
.slice(0, limit || 100)
|
|
139
186
|
.keySeq()
|
|
140
187
|
.map((id) => this.entries.get(id));
|
|
@@ -146,7 +193,15 @@ class Aggregator {
|
|
|
146
193
|
: rawData.map((entry) => this.formatter.formatObject(entry, output));
|
|
147
194
|
}
|
|
148
195
|
|
|
196
|
+
reset() {
|
|
197
|
+
this.entries = new Map();
|
|
198
|
+
}
|
|
199
|
+
|
|
149
200
|
gc() {
|
|
201
|
+
if (this.entryGc) {
|
|
202
|
+
this.entries = this.entries.map(this.entryGc);
|
|
203
|
+
}
|
|
204
|
+
|
|
150
205
|
if (this.entries.size < this.gcSize) {
|
|
151
206
|
return;
|
|
152
207
|
}
|
|
@@ -169,10 +224,10 @@ class Aggregator {
|
|
|
169
224
|
return this.entries
|
|
170
225
|
.map((entry) => {
|
|
171
226
|
const plain = entry.toJS();
|
|
172
|
-
plain.speed =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
227
|
+
plain.speed = entry
|
|
228
|
+
.get('speed')
|
|
229
|
+
.map((speed) => speed.toJSON())
|
|
230
|
+
.toObject();
|
|
176
231
|
return plain;
|
|
177
232
|
})
|
|
178
233
|
.valueSeq()
|
|
@@ -183,21 +238,59 @@ class Aggregator {
|
|
|
183
238
|
for (const item of data) {
|
|
184
239
|
const { speed, ...rest } = item;
|
|
185
240
|
// fromJS deep-converts everything to Immutable structures.
|
|
186
|
-
// Signature headers must stay as a plain object (used with Object.entries)
|
|
187
|
-
// and addresses must be a Set, not a List — fix both after conversion.
|
|
241
|
+
// Signature headers must stay as a plain object (used with Object.entries).
|
|
188
242
|
let entry = fromJS(rest);
|
|
189
243
|
if (entry.hasIn(['signature', 'headers'])) {
|
|
190
244
|
entry = entry.setIn(['signature', 'headers'], rest.signature.headers);
|
|
191
245
|
}
|
|
192
|
-
|
|
193
|
-
|
|
246
|
+
// addresses/signatures are Map<id, lastSeen>. Dumps from before that
|
|
247
|
+
// stored plain arrays, which the enrichers can't update — discard them
|
|
248
|
+
// and let the counts rebuild from live traffic.
|
|
249
|
+
for (const key of ['addresses', 'signatures']) {
|
|
250
|
+
if (List.isList(entry.get(key))) {
|
|
251
|
+
entry = entry.delete(key);
|
|
252
|
+
}
|
|
194
253
|
}
|
|
195
254
|
entry = entry
|
|
196
255
|
.setIn(['speed', 'per_minute'], Speed.fromJSON(speed.per_minute))
|
|
197
|
-
.setIn(['speed', 'per_hour'], Speed.fromJSON(speed.per_hour))
|
|
256
|
+
.setIn(['speed', 'per_hour'], Speed.fromJSON(speed.per_hour))
|
|
257
|
+
.setIn(
|
|
258
|
+
['speed', '2xx_per_minute'],
|
|
259
|
+
speed['2xx_per_minute']
|
|
260
|
+
? Speed.fromJSON(speed['2xx_per_minute'])
|
|
261
|
+
: new Speed(60, 15)
|
|
262
|
+
)
|
|
263
|
+
.setIn(
|
|
264
|
+
['speed', '2xx_per_hour'],
|
|
265
|
+
speed['2xx_per_hour']
|
|
266
|
+
? Speed.fromJSON(speed['2xx_per_hour'])
|
|
267
|
+
: new Speed(3600, 24)
|
|
268
|
+
)
|
|
269
|
+
.setIn(
|
|
270
|
+
['speed', '4xx_per_minute'],
|
|
271
|
+
speed['4xx_per_minute']
|
|
272
|
+
? Speed.fromJSON(speed['4xx_per_minute'])
|
|
273
|
+
: new Speed(60, 15)
|
|
274
|
+
)
|
|
275
|
+
.setIn(
|
|
276
|
+
['speed', '4xx_per_hour'],
|
|
277
|
+
speed['4xx_per_hour']
|
|
278
|
+
? Speed.fromJSON(speed['4xx_per_hour'])
|
|
279
|
+
: new Speed(3600, 24)
|
|
280
|
+
);
|
|
198
281
|
this.entries = this.entries.set(rest.id, entry);
|
|
199
282
|
}
|
|
283
|
+
// Drop members that went stale while the process was down
|
|
284
|
+
if (this.entryGc) {
|
|
285
|
+
this.entries = this.entries.map(this.entryGc);
|
|
286
|
+
}
|
|
200
287
|
}
|
|
201
288
|
}
|
|
202
289
|
|
|
203
|
-
module.exports = {
|
|
290
|
+
module.exports = {
|
|
291
|
+
Aggregator,
|
|
292
|
+
defaultFormatter,
|
|
293
|
+
defaultEnricher,
|
|
294
|
+
lastSeen,
|
|
295
|
+
statusCount,
|
|
296
|
+
};
|
package/src/lib/formatter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const chalk = require('chalk');
|
|
1
|
+
const { default: chalk } = require('chalk');
|
|
2
2
|
|
|
3
3
|
const colorize = (name, value, output) => {
|
|
4
4
|
if (output === 'console') {
|
|
@@ -65,6 +65,15 @@ class Formatter {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
clone() {
|
|
69
|
+
const formatter = new Formatter();
|
|
70
|
+
formatter.formats = [...this.formats];
|
|
71
|
+
formatter.colors = { ...this.colors };
|
|
72
|
+
formatter.output = this.output;
|
|
73
|
+
|
|
74
|
+
return formatter;
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
setOutput(output) {
|
|
69
78
|
this.output = output;
|
|
70
79
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the latest `capacity` logs of a pipeline node, newest first.
|
|
3
|
+
* dump() / load() let the persistence module save it across restarts.
|
|
4
|
+
*/
|
|
5
|
+
const { fromJS } = require('immutable');
|
|
6
|
+
|
|
7
|
+
class LogBuffer {
|
|
8
|
+
constructor(capacity = 1000) {
|
|
9
|
+
this.capacity = capacity;
|
|
10
|
+
this.buffer = new Array(capacity);
|
|
11
|
+
this.pointer = 0;
|
|
12
|
+
this.size = 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
push(item) {
|
|
16
|
+
this.buffer[this.pointer] = item;
|
|
17
|
+
this.pointer = (this.pointer + 1) % this.capacity;
|
|
18
|
+
if (this.size < this.capacity) {
|
|
19
|
+
this.size++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toArray() {
|
|
24
|
+
if (this.size < this.capacity) {
|
|
25
|
+
return this.buffer.slice(0, this.size).reverse();
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
...this.buffer.slice(this.pointer),
|
|
29
|
+
...this.buffer.slice(0, this.pointer),
|
|
30
|
+
].reverse();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Oldest first, never more than capacity
|
|
34
|
+
dump() {
|
|
35
|
+
return this.toArray()
|
|
36
|
+
.reverse()
|
|
37
|
+
.map((log) => log.toJS());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
load(data) {
|
|
41
|
+
data.slice(-this.capacity).forEach((item) => this.push(fromJS(item)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = LogBuffer;
|
package/src/lib/persistence.js
CHANGED
|
@@ -9,6 +9,16 @@ const aggregators = Object.create(null);
|
|
|
9
9
|
|
|
10
10
|
const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
|
|
11
11
|
|
|
12
|
+
// Encode any string into a name accepted by register(). Characters outside
|
|
13
|
+
// [A-Za-z0-9.-], including '_' itself, become _<hex code point>_, so distinct
|
|
14
|
+
// inputs never map to the same name.
|
|
15
|
+
function safeName(name) {
|
|
16
|
+
return String(name).replace(
|
|
17
|
+
/[^A-Za-z0-9.-]/gu,
|
|
18
|
+
(c) => `_${c.codePointAt(0).toString(16)}_`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
function register(name, aggregator) {
|
|
13
23
|
if (!SAFE_NAME.test(name)) {
|
|
14
24
|
throw new Error(`Invalid aggregator name for persistence: "${name}"`);
|
|
@@ -69,4 +79,4 @@ function load(dir) {
|
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
module.exports = { register, dump, load };
|
|
82
|
+
module.exports = { register, dump, load, safeName };
|