@kne/fastify-app-manager 0.1.8 → 0.1.9

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.
@@ -3,10 +3,12 @@ const path = require('node:path');
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('fs-extra');
5
5
  const portPool = require('../utils/portPool');
6
- const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys } = require('../utils/env');
6
+ const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys, isSystemEnvKey, omitSystemEnv } = require('../utils/env');
7
7
  const { prepareVersionArtifact } = require('../utils/version');
8
8
  const { injectEntryHtml } = require('../utils/entryInject');
9
- const { ensureLogFiles, readLogTail, readLastLines, appendLog } = require('../utils/logFiles');
9
+ const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = require('../utils/logFiles');
10
+ const { currentFileName, isCurrentFileName, resolveLogFile, listLogFiles } = require('../utils/logRotate');
11
+ const { toIsoInTz, convertLogText } = require('../utils/logTime');
10
12
  const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
11
13
  const { listSqlFiles } = require('../utils/validatePackage');
12
14
  const pm2Util = require('../utils/pm2');
@@ -116,7 +118,7 @@ module.exports = fp(async (fastify, options) => {
116
118
  domain: domain || null,
117
119
  icon: icon || null,
118
120
  description: description || null,
119
- env: env || {},
121
+ env: omitSystemEnv(env),
120
122
  pm2Config: pm2Config || {},
121
123
  options: normalizedOptions,
122
124
  port,
@@ -150,7 +152,7 @@ module.exports = fp(async (fastify, options) => {
150
152
  }
151
153
  if (patch.options && patch.options.secretEnvKeys !== undefined) {
152
154
  patch.options = Object.assign({}, patch.options, {
153
- secretEnvKeys: normalizeSecretKeys(patch.options.secretEnvKeys)
155
+ secretEnvKeys: normalizeSecretKeys([...resolveSecretEnvKeys(app), ...patch.options.secretEnvKeys])
154
156
  });
155
157
  }
156
158
  if (category !== undefined) {
@@ -165,10 +167,10 @@ module.exports = fp(async (fastify, options) => {
165
167
 
166
168
  const saveEnv = async ({ name, env, secretEnvKeys: nextSecretKeys } = {}) => {
167
169
  const app = await getByName(name);
168
- const currentSecrets = resolveSecretEnvKeys(app);
169
- const secretEnvKeys = nextSecretKeys !== undefined ? normalizeSecretKeys(nextSecretKeys) : currentSecrets;
170
+ // 密钥类型不可撤销,只随变量删除而移除
171
+ const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
170
172
  const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
171
- const cleanedSecrets = secretEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(nextEnv, key));
173
+ const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key) && Object.prototype.hasOwnProperty.call(nextEnv, key));
172
174
  await app.update({
173
175
  env: nextEnv,
174
176
  options: Object.assign({}, app.options || {}, { secretEnvKeys: cleanedSecrets })
@@ -671,10 +673,103 @@ module.exports = fp(async (fastify, options) => {
671
673
  };
672
674
  };
673
675
 
674
- const logs = async ({ name, stream = 'out', perPage = 100, currentPage = 1, beforeLine } = {}) => {
676
+ const logsDirOf = app => path.join(app.rootPath, 'logs');
677
+ const currentLogFile = (app, stream) => path.join(logsDirOf(app), currentFileName(stream));
678
+
679
+ const resolveExistingLogFile = async (app, fileName) => {
680
+ let filePath;
681
+ try {
682
+ filePath = resolveLogFile(logsDirOf(app), fileName);
683
+ } catch (e) {
684
+ httpError(400, e.message);
685
+ }
686
+ if (!(await fs.pathExists(filePath))) {
687
+ httpError(404, `log file ${fileName} not found`);
688
+ }
689
+ return filePath;
690
+ };
691
+
692
+ const logs = async ({ name, stream = 'out', file, perPage = 100, currentPage = 1, beforeLine } = {}) => {
675
693
  const app = await getByName(name);
676
- const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
677
- return readLogTail(file, { perPage, currentPage, beforeLine });
694
+ const filePath = file ? await resolveExistingLogFile(app, file) : currentLogFile(app, stream);
695
+ return readLogTail(filePath, { perPage, currentPage, beforeLine, timezone: options.logTimezone });
696
+ };
697
+
698
+ const logFiles = async ({ name }) => {
699
+ const app = await getByName(name);
700
+ const pageData = (await listLogFiles(logsDirOf(app))).map(item => Object.assign({}, item, { mtime: toIsoInTz(item.mtime, options.logTimezone) }));
701
+ return { pageData, totalCount: pageData.length };
702
+ };
703
+
704
+ const resolveAppLogFile = async ({ name, file }) => {
705
+ const app = await getByName(name);
706
+ const filePath = await resolveExistingLogFile(app, file);
707
+ const { size } = await fs.stat(filePath);
708
+ return { path: filePath, fileName: file, size, compressed: file.endsWith('.gz') };
709
+ };
710
+
711
+ const resolveAppLogFiles = async ({ name, files = [] }) => {
712
+ const app = await getByName(name);
713
+ const items = [];
714
+ for (const fileName of Array.from(new Set(files))) {
715
+ const filePath = await resolveExistingLogFile(app, fileName);
716
+ const { size, mtime } = await fs.stat(filePath);
717
+ items.push({ path: filePath, fileName, size, mtime, compressed: fileName.endsWith('.gz') });
718
+ }
719
+ return { name: app.name, files: items };
720
+ };
721
+
722
+ const removeLogFiles = async ({ name, files = [] }) => {
723
+ const app = await getByName(name);
724
+ const targets = [];
725
+ for (const fileName of files) {
726
+ if (isCurrentFileName(fileName)) {
727
+ httpError(400, `cannot remove current log file ${fileName}`);
728
+ }
729
+ try {
730
+ targets.push({ fileName, filePath: resolveLogFile(logsDirOf(app), fileName) });
731
+ } catch (e) {
732
+ httpError(400, e.message);
733
+ }
734
+ }
735
+ const removed = [];
736
+ for (const { fileName, filePath } of targets) {
737
+ if (await fs.pathExists(filePath)) {
738
+ await fs.remove(filePath);
739
+ removed.push(fileName);
740
+ }
741
+ }
742
+ return { removed };
743
+ };
744
+
745
+ const getLoadStore = () => fastify[options.name].loadStore;
746
+
747
+ const load = async ({ name }) => {
748
+ const app = await getByName(name);
749
+ const store = getLoadStore();
750
+ return {
751
+ intervalMs: options.loadSampleIntervalMs > 0 ? options.loadSampleIntervalMs : 0,
752
+ current: store ? store.latest(app.name) : null,
753
+ pageData: store ? store.history(app.name) : []
754
+ };
755
+ };
756
+
757
+ const emitAppLog = async (appName, stream, content) => {
758
+ const hub = getLogHub();
759
+ if (!hub) {
760
+ return;
761
+ }
762
+ const app = await models.app.findOne({ where: { name: appName } });
763
+ if (!app) {
764
+ return;
765
+ }
766
+ hub.emit(`log:${appName}`, {
767
+ appName,
768
+ stream,
769
+ content: convertLogText(content, options.logTimezone),
770
+ line: await countLines(currentLogFile(app, stream)),
771
+ loggedAt: toIsoInTz(new Date(), options.logTimezone)
772
+ });
678
773
  };
679
774
 
680
775
  const findByHost = async (host, { running = true } = {}) => {
@@ -707,34 +802,29 @@ module.exports = fp(async (fastify, options) => {
707
802
  syncAllStatuses,
708
803
  remove,
709
804
  logs,
805
+ logFiles,
806
+ resolveAppLogFile,
807
+ resolveAppLogFiles,
808
+ removeLogFiles,
809
+ load,
810
+ getLoadStore,
710
811
  toPublicApp,
711
812
  mountPrefix,
712
813
  findByHost,
713
814
  findByPathName,
714
815
  readLastLines: async (name, stream, n) => {
715
816
  const app = await getByName(name);
716
- const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
717
- return readLastLines(file, n);
817
+ return readLastLines(currentLogFile(app, stream), n, { timezone: options.logTimezone });
718
818
  },
719
819
  getLogHub,
820
+ emitAppLog,
720
821
  appendAppLog: async (appName, stream, content) => {
721
822
  const app = await models.app.findOne({ where: { name: appName } });
722
823
  if (!app) {
723
824
  return;
724
825
  }
725
- const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
726
- await appendLog(file, content);
727
- const hub = getLogHub();
728
- if (hub) {
729
- const { totalCount } = await readLogTail(file, { perPage: 1, currentPage: 1 });
730
- hub.emit(`log:${appName}`, {
731
- appName,
732
- stream,
733
- content,
734
- line: totalCount,
735
- loggedAt: new Date().toISOString()
736
- });
737
- }
826
+ await appendLog(currentLogFile(app, stream), content);
827
+ await emitAppLog(appName, stream, content);
738
828
  }
739
829
  }
740
830
  });
@@ -1,14 +1,34 @@
1
1
  const fp = require('fastify-plugin');
2
+ const path = require('node:path');
3
+ const fs = require('fs-extra');
2
4
  const { EventEmitter } = require('node:events');
3
5
  const pm2Util = require('../utils/pm2');
6
+ const { LOG_STREAMS, rotateIfNeeded, compressArchive, applyRetention } = require('../utils/logRotate');
7
+ const { summarizeProcesses, createLoadStore } = require('../utils/loadMonitor');
8
+ const { createRequestMetrics } = require('../utils/requestMetrics');
9
+ const { toIsoInTz } = require('../utils/logTime');
10
+
11
+ const OFFLINE_LOAD = { status: 'offline', cpu: 0, memory: 0, instances: 0, pids: [], uptime: 0, restarts: 0 };
4
12
 
5
13
  module.exports = fp(async (fastify, options) => {
6
14
  const logHub = new EventEmitter();
7
15
  logHub.setMaxListeners(0);
8
16
  fastify[options.name].logHub = logHub;
9
17
 
18
+ const loadStore = createLoadStore({
19
+ size: options.loadSampleIntervalMs > 0 ? Math.ceil((options.loadHistoryMinutes * 60 * 1000) / options.loadSampleIntervalMs) : 1
20
+ });
21
+ fastify[options.name].loadStore = loadStore;
22
+
23
+ const requestMetrics = createRequestMetrics({ intervalMs: options.loadSampleIntervalMs });
24
+ fastify[options.name].requestMetrics = requestMetrics;
25
+
10
26
  let bus = null;
11
27
  let connected = false;
28
+ let rotateTimer = null;
29
+ let rotating = null;
30
+ let loadTimer = null;
31
+ let sampling = null;
12
32
 
13
33
  const mapPm2NameToApp = async pm2Name => {
14
34
  const { models } = fastify[options.name];
@@ -41,7 +61,8 @@ module.exports = fp(async (fastify, options) => {
41
61
  if (!content) {
42
62
  return;
43
63
  }
44
- await fastify[options.name].services.app.appendAppLog(app.name, stream, content);
64
+ // PM2 already writes out_file/error_file; the bus only feeds SSE subscribers.
65
+ await fastify[options.name].services.app.emitAppLog(app.name, stream, content);
45
66
  } catch (e) {
46
67
  fastify.log.debug({ err: e }, 'log bus handler error');
47
68
  }
@@ -52,16 +73,33 @@ module.exports = fp(async (fastify, options) => {
52
73
  fastify.log.warn({ err: e }, 'pm2 launchBus failed');
53
74
  }
54
75
 
76
+ if (options.logRotateIntervalMs > 0) {
77
+ rotateTimer = setInterval(() => {
78
+ rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
79
+ }, options.logRotateIntervalMs);
80
+ rotateTimer.unref?.();
81
+ }
82
+
83
+ if (options.loadSampleIntervalMs > 0) {
84
+ loadTimer = setInterval(() => {
85
+ sampleLoad().catch(err => fastify.log.debug({ err }, 'load sampling failed'));
86
+ }, options.loadSampleIntervalMs);
87
+ loadTimer.unref?.();
88
+ }
89
+
55
90
  // 父应用重启后不阻塞 ready:后台按 PM2 真实状态异步对齐 DB
56
91
  setImmediate(() => {
57
92
  reconcile().catch(err => {
58
93
  fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
59
94
  });
95
+ rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
96
+ if (options.loadSampleIntervalMs > 0) {
97
+ sampleLoad().catch(err => fastify.log.debug({ err }, 'load sampling failed'));
98
+ }
60
99
  });
61
100
  };
62
101
 
63
102
  const fsEnsureAppsRoot = async () => {
64
- const fs = require('fs-extra');
65
103
  await fs.ensureDir(options.appsRoot);
66
104
  };
67
105
 
@@ -73,7 +111,126 @@ module.exports = fp(async (fastify, options) => {
73
111
  return [];
74
112
  };
75
113
 
114
+ const runRotation = async ({ now = new Date() } = {}) => {
115
+ const { models } = fastify[options.name];
116
+ const apps = await models.app.findAll({ attributes: ['name', 'rootPath'] });
117
+ const targets = [];
118
+ for (const app of apps) {
119
+ const logsDir = path.join(app.rootPath, 'logs');
120
+ if (await fs.pathExists(logsDir)) {
121
+ targets.push({ name: app.name, logsDir });
122
+ }
123
+ }
124
+
125
+ const rotated = [];
126
+ for (const { name, logsDir } of targets) {
127
+ for (const stream of LOG_STREAMS) {
128
+ try {
129
+ const result = await rotateIfNeeded({
130
+ logsDir,
131
+ stream,
132
+ maxSize: options.logMaxSize,
133
+ daily: options.logRotateDaily,
134
+ timezone: options.logTimezone,
135
+ now
136
+ });
137
+ if (result) {
138
+ rotated.push(Object.assign({ name, stream }, result));
139
+ }
140
+ } catch (e) {
141
+ fastify.log.warn({ err: e, app: name, stream }, 'rotate log file failed');
142
+ }
143
+ }
144
+ }
145
+
146
+ // Until PM2 reopens its handles it keeps writing into the renamed file, so compress only afterwards.
147
+ if (rotated.length && connected) {
148
+ try {
149
+ await pm2Util.reloadLogs();
150
+ } catch (e) {
151
+ fastify.log.warn({ err: e }, 'pm2 reloadLogs failed');
152
+ }
153
+ }
154
+
155
+ if (options.logCompress) {
156
+ for (const item of rotated) {
157
+ try {
158
+ item.archive = await compressArchive(item.archive);
159
+ } catch (e) {
160
+ fastify.log.warn({ err: e, app: item.name, archive: item.archive }, 'compress log archive failed');
161
+ }
162
+ }
163
+ }
164
+
165
+ for (const { name, logsDir } of targets) {
166
+ for (const stream of LOG_STREAMS) {
167
+ try {
168
+ await applyRetention({ logsDir, stream, maxFiles: options.logRetentionMaxFiles, maxDays: options.logRetentionDays, now });
169
+ } catch (e) {
170
+ fastify.log.warn({ err: e, app: name, stream }, 'log retention failed');
171
+ }
172
+ }
173
+ }
174
+
175
+ return rotated.map(({ name, stream, archive, reason }) => ({ name, stream, fileName: path.basename(archive), reason }));
176
+ };
177
+
178
+ const rotateLogs = async opts => {
179
+ if (rotating) {
180
+ return rotating;
181
+ }
182
+ rotating = runRotation(opts).finally(() => {
183
+ rotating = null;
184
+ });
185
+ return rotating;
186
+ };
187
+
188
+ const runSampling = async ({ now = new Date() } = {}) => {
189
+ if (!connected) {
190
+ return [];
191
+ }
192
+ const { models } = fastify[options.name];
193
+ const summary = summarizeProcesses(await pm2Util.list(), { now: now.getTime() });
194
+ const apps = await models.app.findAll({ attributes: ['name', 'pm2Name'] });
195
+ const base = { ts: now.getTime(), sampledAt: toIsoInTz(now, options.logTimezone) };
196
+ const samples = apps.map(app => {
197
+ const sample = Object.assign({ name: app.name }, base, summary.get(app.pm2Name) || OFFLINE_LOAD, {
198
+ requests: requestMetrics.snapshot(app.name, { now: base.ts })
199
+ });
200
+ loadStore.push(app.name, sample);
201
+ return sample;
202
+ });
203
+ const names = apps.map(app => app.name);
204
+ loadStore.retain(names);
205
+ requestMetrics.retain(names);
206
+ return samples;
207
+ };
208
+
209
+ const sampleLoad = async opts => {
210
+ if (sampling) {
211
+ return sampling;
212
+ }
213
+ sampling = runSampling(opts).finally(() => {
214
+ sampling = null;
215
+ });
216
+ return sampling;
217
+ };
218
+
76
219
  const onClose = async () => {
220
+ if (rotateTimer) {
221
+ clearInterval(rotateTimer);
222
+ rotateTimer = null;
223
+ }
224
+ if (loadTimer) {
225
+ clearInterval(loadTimer);
226
+ loadTimer = null;
227
+ }
228
+ if (rotating) {
229
+ await rotating.catch(() => {});
230
+ }
231
+ if (sampling) {
232
+ await sampling.catch(() => {});
233
+ }
77
234
  if (bus) {
78
235
  try {
79
236
  bus.close?.();
@@ -87,6 +244,6 @@ module.exports = fp(async (fastify, options) => {
87
244
  };
88
245
 
89
246
  Object.assign(fastify[options.name].services, {
90
- bootstrap: { onReady, onClose, reconcile }
247
+ bootstrap: { onReady, onClose, reconcile, rotateLogs, sampleLoad }
91
248
  });
92
249
  });
package/libs/utils/env.js CHANGED
@@ -105,10 +105,17 @@ const isSecretKey = (key, pattern, secretKeys = []) => {
105
105
  return isPatternSecretKey(key, pattern);
106
106
  };
107
107
 
108
+ // 由系统写入维护:不在接口中返回,也不接受外部新增 / 修改 / 删除
109
+ const SYSTEM_ENV_KEYS = ['DB_TABLE_PREFIX'];
110
+
111
+ const isSystemEnvKey = key => SYSTEM_ENV_KEYS.includes(key);
112
+
113
+ const omitSystemEnv = (appEnv = {}) => Object.fromEntries(Object.entries(appEnv || {}).filter(([key]) => !isSystemEnvKey(key)));
114
+
108
115
  const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
109
116
  const masked = {};
110
117
  const explicit = normalizeSecretKeys(secretEnvKeys);
111
- for (const [key, value] of Object.entries(appEnv || {})) {
118
+ for (const [key, value] of Object.entries(omitSystemEnv(appEnv))) {
112
119
  if (isSecretKey(key, secretEnvKeyPattern, explicit)) {
113
120
  masked[key] = value == null || value === '' ? null : SECRET_MASK;
114
121
  } else {
@@ -128,6 +135,9 @@ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEn
128
135
  const next = Object.assign({}, current);
129
136
  const explicit = normalizeSecretKeys(secretEnvKeys);
130
137
  for (const [key, value] of Object.entries(patch || {})) {
138
+ if (isSystemEnvKey(key)) {
139
+ continue;
140
+ }
131
141
  if (value === null) {
132
142
  delete next[key];
133
143
  continue;
@@ -146,7 +156,7 @@ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEn
146
156
  const collectSecretKeys = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
147
157
  const explicit = normalizeSecretKeys(secretEnvKeys);
148
158
  const fromPattern = Object.keys(appEnv || {}).filter(key => isPatternSecretKey(key, secretEnvKeyPattern));
149
- return normalizeSecretKeys([...explicit, ...fromPattern]);
159
+ return normalizeSecretKeys([...explicit, ...fromPattern]).filter(key => !isSystemEnvKey(key));
150
160
  };
151
161
 
152
162
  const PM2_CONFIG_KEYS = ['exec_mode', 'instances', 'autorestart', 'max_memory_restart', 'max_restarts', 'min_uptime', 'kill_timeout', 'merge_logs'];
@@ -171,6 +181,9 @@ module.exports = {
171
181
  mergeEnv,
172
182
  isPatternSecretKey,
173
183
  isSecretKey,
184
+ SYSTEM_ENV_KEYS,
185
+ isSystemEnvKey,
186
+ omitSystemEnv,
174
187
  maskEnvForResponse,
175
188
  applyEnvPatch,
176
189
  collectSecretKeys,
@@ -0,0 +1,73 @@
1
+ const { EventEmitter } = require('node:events');
2
+
3
+ // cluster 模式下同名进程有多个实例,按 PM2 进程名汇总为一条负载
4
+ const summarizeProcesses = (processes, { now = Date.now() } = {}) => {
5
+ const groups = new Map();
6
+ for (const proc of processes || []) {
7
+ const name = proc?.name || proc?.pm2_env?.name;
8
+ if (!name) {
9
+ continue;
10
+ }
11
+ if (!groups.has(name)) {
12
+ groups.set(name, []);
13
+ }
14
+ groups.get(name).push(proc);
15
+ }
16
+
17
+ const result = new Map();
18
+ for (const [name, list] of groups) {
19
+ const online = list.filter(proc => proc.pm2_env?.status === 'online');
20
+ const startedAt = online.map(proc => Number(proc.pm2_env?.pm_uptime)).filter(value => Number.isFinite(value) && value > 0);
21
+ const cpu = list.reduce((sum, proc) => sum + (Number(proc.monit?.cpu) || 0), 0);
22
+ result.set(name, {
23
+ status: online.length ? 'online' : list[0].pm2_env?.status || 'unknown',
24
+ cpu: Math.round(cpu * 10) / 10,
25
+ memory: list.reduce((sum, proc) => sum + (Number(proc.monit?.memory) || 0), 0),
26
+ instances: list.length,
27
+ pids: online.map(proc => proc.pid).filter(Boolean),
28
+ uptime: startedAt.length ? Math.max(0, now - Math.min(...startedAt)) : 0,
29
+ restarts: list.reduce((sum, proc) => sum + (Number(proc.pm2_env?.restart_time) || 0), 0)
30
+ });
31
+ }
32
+ return result;
33
+ };
34
+
35
+ const createLoadStore = ({ size }) => {
36
+ const capacity = Math.max(1, Math.floor(size) || 1);
37
+ const buffers = new Map();
38
+ const emitter = new EventEmitter();
39
+ emitter.setMaxListeners(0);
40
+
41
+ const push = (name, sample) => {
42
+ const buffer = buffers.get(name) || [];
43
+ buffer.push(sample);
44
+ if (buffer.length > capacity) {
45
+ buffer.splice(0, buffer.length - capacity);
46
+ }
47
+ buffers.set(name, buffer);
48
+ emitter.emit(`load:${name}`, sample);
49
+ };
50
+
51
+ const history = name => (buffers.get(name) || []).slice();
52
+
53
+ const latest = name => {
54
+ const buffer = buffers.get(name);
55
+ return buffer && buffer.length ? buffer[buffer.length - 1] : null;
56
+ };
57
+
58
+ const retain = names => {
59
+ const keep = new Set(names);
60
+ for (const name of buffers.keys()) {
61
+ if (!keep.has(name)) {
62
+ buffers.delete(name);
63
+ }
64
+ }
65
+ };
66
+
67
+ return { capacity, push, history, latest, retain, emitter };
68
+ };
69
+
70
+ module.exports = {
71
+ summarizeProcesses,
72
+ createLoadStore
73
+ };