peekinside 0.0.0-stage → 1.2.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +538 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Validation of the batches apps post to the hub's /api/ingest, and of the spool files crashing
3
+ * apps leave for it.
4
+ *
5
+ * A batch comes from another process, so nothing in it is trusted: the project id becomes a
6
+ * folder name, strings are capped, numbers are checked. A malformed batch is refused as a whole,
7
+ * while a malformed record or count is only skipped, so one bad request never loses the rest.
8
+ */
9
+
10
+ import { PROJECT_ID } from './store.js';
11
+
12
+ // Requests per batch; the SDK sends at most 200.
13
+ export const MAX_BATCH = 1000;
14
+ // Exceptions per batch.
15
+ export const MAX_EXCEPTIONS_BATCH = 500;
16
+ // Job runs and log lines per batch.
17
+ export const MAX_JOBS_BATCH = 1000;
18
+ export const MAX_LOGS_BATCH = 2000;
19
+ // Request ids are UUIDs in practice. They appear in URLs and index files, so only safe
20
+ // characters are accepted.
21
+ export const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
22
+ const METHOD = /^[A-Z]{1,16}$/;
23
+ const MAX_BLOCKED_ENTRIES = 20;
24
+ const FINGERPRINT = /^[0-9a-f]{16}$/;
25
+ // Timeline counters a request may carry, in the contract's order: counts are integers, times are
26
+ // milliseconds.
27
+ const STAT_KEYS = ['queries', 'queryTime', 'slowQueries', 'nPlusOne', 'duplicates', 'http', 'httpTime', 'logs', 'loopBlock'];
28
+ const STAT_TIMES = new Set(['queryTime', 'httpTime', 'loopBlock']);
29
+ const MAX_STAT = 1e9;
30
+ const MECHANISMS = new Set(['express', 'koa', 'fastify', 'http', 'uncaught', 'unhandledRejection', 'report', 'job']);
31
+ const JOB_STATUSES = new Set(['ok', 'failed']);
32
+ const JOB_TRIGGERS = new Set(['cron', 'queue', 'manual']);
33
+ const LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);
34
+ const LOG_SOURCES = new Set(['console', 'pino', 'winston']);
35
+ // What the index keeps of a log line; the line in full is its detail.
36
+ const LOG_PREVIEW = 500;
37
+ // What release.js produces.
38
+ const RELEASE = /^[A-Za-z0-9._:@/+-]{1,64}$/;
39
+ // Process samples per batch (the SDK sends one every 10 seconds, more after the hub was down).
40
+ const MAX_SAMPLES = 120;
41
+ const SAMPLE_BYTES = ['rss', 'heapUsed', 'heapTotal', 'external'];
42
+ const SAMPLE_DELAYS = ['loopP50', 'loopP99', 'loopMax'];
43
+
44
+ /**
45
+ * A validated ingest batch: `{ project, instance, requests, exceptions, samples, blocked }`.
46
+ */
47
+ export class IngestPayload {
48
+ /**
49
+ * Validates a parsed request body.
50
+ *
51
+ * @param {*} body - Parsed JSON
52
+ * @returns {IngestPayload|null} null when the project, the process details or the request list
53
+ * is missing or malformed, or the batch is too large. `exceptions` is optional.
54
+ */
55
+ static parse(body) {
56
+ const project = readProject(body && body.project);
57
+ const instance = readInstance(body && body.instance);
58
+ const requests = body && Array.isArray(body.requests) ? body.requests : null;
59
+ const exceptions = body && body.exceptions != null ? body.exceptions : [];
60
+ if (!project || !instance || !requests || requests.length > MAX_BATCH) {
61
+ return null;
62
+ }
63
+ if (!Array.isArray(exceptions) || exceptions.length > MAX_EXCEPTIONS_BATCH) {
64
+ return null;
65
+ }
66
+ const jobs = body.jobs != null ? body.jobs : [];
67
+ const logs = body.logs != null ? body.logs : [];
68
+ if (!Array.isArray(jobs) || jobs.length > MAX_JOBS_BATCH || !Array.isArray(logs) || logs.length > MAX_LOGS_BATCH) {
69
+ return null;
70
+ }
71
+ return new IngestPayload({ project, instance, requests, exceptions, jobs, logs, samples: body.samples, blocked: body.blocked, hasExceptions: Array.isArray(body.exceptions) });
72
+ }
73
+
74
+ /**
75
+ * Use IngestPayload.parse().
76
+ *
77
+ * @param {Object} parts
78
+ * @param {{ id: string, name: string, root: string }} parts.project - Validated project
79
+ * @param {Object} parts.instance - Validated process details
80
+ * @param {Array} parts.requests - Records as sent
81
+ * @param {Array} [parts.exceptions] - Exception records as sent
82
+ * @param {Array} [parts.jobs] - Job runs as sent
83
+ * @param {Array} [parts.logs] - Log lines as sent
84
+ * @param {*} [parts.samples] - Process samples (memory, CPU, event-loop delay), as sent
85
+ * @param {*} parts.blocked - Counts of unrecorded server requests, as sent
86
+ * @param {boolean} [parts.hasExceptions=false] - Whether the batch had an exception list (SDKs
87
+ * from before exceptions don't send one)
88
+ */
89
+ constructor({ project, instance, requests, exceptions = [], jobs = [], logs = [], samples, blocked, hasExceptions = false }) {
90
+ this.project = project;
91
+ this.instance = instance;
92
+ /**
93
+ * The valid records, flattened into what the store keeps
94
+ * @type {Object[]}
95
+ */
96
+ this.records = requests.map(readRecord).filter(Boolean);
97
+ /**
98
+ * Number of malformed records left out
99
+ * @type {number}
100
+ */
101
+ this.rejected = requests.length - this.records.length;
102
+ /**
103
+ * The valid exceptions, flattened into what the store keeps
104
+ * @type {Object[]}
105
+ */
106
+ this.exceptions = exceptions.map(readException).filter(Boolean);
107
+ /**
108
+ * Number of malformed exceptions left out
109
+ * @type {number}
110
+ */
111
+ this.rejectedExceptions = exceptions.length - this.exceptions.length;
112
+ /**
113
+ * Whether the batch had an exception list
114
+ * @type {boolean}
115
+ */
116
+ this.hasExceptions = hasExceptions;
117
+ /**
118
+ * The valid process samples, oldest first
119
+ * @type {Object[]}
120
+ */
121
+ this.samples = readSamples(samples);
122
+ /**
123
+ * The valid job runs and log lines, flattened into what the store keeps
124
+ * @type {Object[]}
125
+ */
126
+ this.jobs = jobs.map(readJob).filter(Boolean);
127
+ this.logs = logs.map(readLog).filter(Boolean);
128
+ this.rejectedJobs = jobs.length - this.jobs.length;
129
+ this.rejectedLogs = logs.length - this.logs.length;
130
+ /**
131
+ * The valid counts of server requests the SDK didn't record
132
+ * @type {{ kind: 'host'|'client', value: string, count: number }[]}
133
+ */
134
+ this.blocked = readBlocked(blocked);
135
+ }
136
+ }
137
+
138
+ function readProject(value) {
139
+ if (!value || typeof value !== 'object') return null;
140
+ const { id, name, root } = value;
141
+ if (typeof id !== 'string' || !PROJECT_ID.test(id)) return null;
142
+ if (typeof name !== 'string' || !name.trim() || name.length > 120) return null;
143
+ if (typeof root !== 'string' || root.length > 4096) return null;
144
+ return { id, name: name.trim(), root };
145
+ }
146
+
147
+ function readInstance(value) {
148
+ if (!value || typeof value !== 'object' || !Number.isInteger(value.pid) || value.pid <= 0) return null;
149
+ return {
150
+ pid: value.pid,
151
+ port: Number.isInteger(value.port) && value.port > 0 && value.port < 65536 ? value.port : null,
152
+ framework: text(value.framework, 24),
153
+ node: text(value.node, 24),
154
+ sdk: text(value.sdk, 24),
155
+ environment: text(value.environment, 32),
156
+ release: release(value.release),
157
+ startedAt: Number.isFinite(value.startedAt) ? value.startedAt : Date.now()
158
+ };
159
+ }
160
+
161
+ // Flattens one SDK record into what the store keeps; null when it is malformed.
162
+ function readRecord(value) {
163
+ if (!value || typeof value !== 'object') return null;
164
+ if (typeof value.id !== 'string' || !REQUEST_ID.test(value.id)) return null;
165
+ if (!Number.isFinite(value.occurredAt) || typeof value.method !== 'string' || !METHOD.test(value.method)) return null;
166
+ const status = value.status;
167
+ if (status !== null && !(Number.isInteger(status) && status >= 100 && status <= 599)) return null;
168
+ const summary = value.summary && typeof value.summary === 'object' ? value.summary : {};
169
+ const detail = value.detail && typeof value.detail === 'object' && !Array.isArray(value.detail) ? value.detail : {};
170
+
171
+ return {
172
+ id: value.id,
173
+ occurredAt: value.occurredAt,
174
+ environment: text(value.environment, 32) || 'development',
175
+ release: release(value.release),
176
+ method: value.method,
177
+ status,
178
+ duration: Number.isFinite(value.duration) && value.duration >= 0 ? value.duration : null,
179
+ requestSize: byteCount(value.requestSize),
180
+ responseSize: byteCount(value.responseSize),
181
+ aborted: value.aborted === true,
182
+ exceptionCount: byteCount(value.exceptionCount),
183
+ stats: readStats(value.stats),
184
+ url: text(summary.url, 16384),
185
+ path: text(summary.path, 16384),
186
+ contentType: text(summary.contentType, 256),
187
+ user: readUser(summary.user),
188
+ detail
189
+ };
190
+ }
191
+
192
+ // The timeline counters, keeping only the known keys and dropping values that aren't valid
193
+ // rather than the record: a newer SDK may send more, an older one sends none (null).
194
+ function readStats(value) {
195
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
196
+ const stats = {};
197
+ for (const key of STAT_KEYS) {
198
+ const number = value[key];
199
+ const valid = STAT_TIMES.has(key) ? Number.isFinite(number) : Number.isInteger(number);
200
+ if (valid && number >= 0 && number <= MAX_STAT) stats[key] = number;
201
+ }
202
+ return stats;
203
+ }
204
+
205
+ // Flattens one SDK exception into what the store keeps: its summary's fields next to the record's;
206
+ // null when it is malformed. The fingerprint and mechanism must be valid, since the dashboard
207
+ // groups and labels by them.
208
+ function readException(value) {
209
+ if (!value || typeof value !== 'object') return null;
210
+ if (typeof value.id !== 'string' || !REQUEST_ID.test(value.id) || !Number.isFinite(value.occurredAt)) return null;
211
+ if (value.requestId !== null && value.requestId !== undefined && (typeof value.requestId !== 'string' || !REQUEST_ID.test(value.requestId))) return null;
212
+ if (!optionalId(value.jobRunId)) return null;
213
+ const summary = value.summary;
214
+ if (!summary || typeof summary !== 'object') return null;
215
+ if (typeof summary.fingerprint !== 'string' || !FINGERPRINT.test(summary.fingerprint) || !MECHANISMS.has(summary.mechanism)) return null;
216
+ const detail = value.detail && typeof value.detail === 'object' && !Array.isArray(value.detail) ? value.detail : {};
217
+
218
+ return {
219
+ id: value.id,
220
+ occurredAt: value.occurredAt,
221
+ environment: text(value.environment, 32) || 'development',
222
+ release: release(value.release),
223
+ handled: value.handled === true,
224
+ fatal: value.fatal === true,
225
+ requestId: value.requestId || null,
226
+ jobRunId: value.jobRunId || null,
227
+ name: text(summary.name, 200) || 'Error',
228
+ message: text(summary.message, 2000),
229
+ code: typeof summary.code === 'string' ? summary.code.slice(0, 64) : null,
230
+ fingerprint: summary.fingerprint,
231
+ mechanism: summary.mechanism,
232
+ location: readLocation(summary.location),
233
+ request: readRequestLine(summary.request),
234
+ job: summary.job && typeof summary.job === 'object' && typeof summary.job.name === 'string' ? { name: summary.job.name.slice(0, 128) } : null,
235
+ user: readUser(summary.user),
236
+ detail
237
+ };
238
+ }
239
+
240
+ // A job run: its readable fields next to its summary's (the error it failed with, the user).
241
+ function readJob(value) {
242
+ if (!value || typeof value !== 'object') return null;
243
+ if (typeof value.id !== 'string' || !REQUEST_ID.test(value.id) || !Number.isFinite(value.occurredAt)) return null;
244
+ if (typeof value.name !== 'string' || !value.name.trim() || !JOB_STATUSES.has(value.status)) return null;
245
+ const summary = value.summary && typeof value.summary === 'object' ? value.summary : {};
246
+ const detail = value.detail && typeof value.detail === 'object' && !Array.isArray(value.detail) ? value.detail : {};
247
+ const error = summary.error && typeof summary.error === 'object' ? { name: text(summary.error.name, 200) || 'Error', message: text(summary.error.message, 1000) } : null;
248
+ return {
249
+ id: value.id,
250
+ occurredAt: value.occurredAt,
251
+ environment: text(value.environment, 32) || 'development',
252
+ release: release(value.release),
253
+ name: value.name.trim().slice(0, 128),
254
+ queue: typeof value.queue === 'string' ? value.queue.slice(0, 128) : null,
255
+ schedule: typeof value.schedule === 'string' ? value.schedule.slice(0, 128) : null,
256
+ timezone: typeof value.timezone === 'string' ? value.timezone.slice(0, 64) : null,
257
+ trigger: JOB_TRIGGERS.has(value.trigger) ? value.trigger : 'manual',
258
+ status: value.status,
259
+ duration: Number.isFinite(value.duration) && value.duration >= 0 ? value.duration : null,
260
+ attempt: Number.isInteger(value.attempt) && value.attempt > 0 ? Math.min(value.attempt, 1e6) : 1,
261
+ exceptionCount: byteCount(value.exceptionCount),
262
+ stats: readStats(value.stats),
263
+ error,
264
+ user: readUser(summary.user),
265
+ detail
266
+ };
267
+ }
268
+
269
+ // A log line: its fields, the start of the line for the list, and the whole line as its detail.
270
+ function readLog(value) {
271
+ if (!value || typeof value !== 'object') return null;
272
+ if (typeof value.id !== 'string' || !REQUEST_ID.test(value.id) || !Number.isFinite(value.occurredAt)) return null;
273
+ if (!LOG_LEVELS.has(value.level) || !optionalId(value.requestId) || !optionalId(value.jobRunId)) return null;
274
+ const summary = value.summary && typeof value.summary === 'object' ? value.summary : {};
275
+ const message = text(summary.message, 4000);
276
+ return {
277
+ id: value.id,
278
+ occurredAt: value.occurredAt,
279
+ environment: text(value.environment, 32) || 'development',
280
+ release: release(value.release),
281
+ level: value.level,
282
+ source: LOG_SOURCES.has(value.source) ? value.source : 'console',
283
+ requestId: value.requestId || null,
284
+ jobRunId: value.jobRunId || null,
285
+ message: message.slice(0, LOG_PREVIEW),
286
+ detail: { message, ...(summary.fields !== undefined ? { fields: summary.fields } : {}) }
287
+ };
288
+ }
289
+
290
+ function optionalId(value) {
291
+ return value === null || value === undefined || (typeof value === 'string' && REQUEST_ID.test(value));
292
+ }
293
+
294
+ // Numbers only: a malformed sample is skipped, a missing delay (no ticks yet) is null.
295
+ function readSamples(value) {
296
+ if (!Array.isArray(value)) return [];
297
+ return value.slice(-MAX_SAMPLES).flatMap((sample) => {
298
+ if (!sample || typeof sample !== 'object' || !Number.isFinite(sample.at)) return [];
299
+ const clean = { at: sample.at };
300
+ for (const key of SAMPLE_BYTES) {
301
+ const number = sample[key];
302
+ if (!Number.isFinite(number) || number < 0 || number > 1e13) return [];
303
+ clean[key] = Math.round(number);
304
+ }
305
+ clean.cpu = Number.isFinite(sample.cpu) && sample.cpu >= 0 && sample.cpu <= 10_000 ? sample.cpu : null;
306
+ for (const key of SAMPLE_DELAYS) {
307
+ const number = sample[key];
308
+ clean[key] = Number.isFinite(number) && number >= 0 && number <= 1e7 ? number : null;
309
+ }
310
+ return [clean];
311
+ });
312
+ }
313
+
314
+ function readLocation(value) {
315
+ if (!value || typeof value !== 'object' || typeof value.file !== 'string') return null;
316
+ return {
317
+ file: value.file.slice(0, 4096),
318
+ line: positiveInt(value.line),
319
+ column: positiveInt(value.column),
320
+ function: typeof value.function === 'string' ? value.function.slice(0, 1024) : null
321
+ };
322
+ }
323
+
324
+ // Who a request or exception was for: short strings only, null when it names nobody.
325
+ function readUser(value) {
326
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
327
+ const user = { id: text(value.id, 128) || null, email: text(value.email, 254) || null, name: text(value.name, 128) || null };
328
+ return user.id || user.email || user.name ? user : null;
329
+ }
330
+
331
+ function readRequestLine(value) {
332
+ if (!value || typeof value !== 'object' || typeof value.method !== 'string' || typeof value.path !== 'string') return null;
333
+ return { method: value.method.slice(0, 16), path: value.path.slice(0, 16384) };
334
+ }
335
+
336
+ function positiveInt(value) {
337
+ return Number.isInteger(value) && value >= 0 ? value : null;
338
+ }
339
+
340
+ // Counts of requests the SDK didn't record because they reached a server. Malformed entries are
341
+ // ignored rather than failing the batch.
342
+ function readBlocked(value) {
343
+ if (!Array.isArray(value)) return [];
344
+ return value.slice(0, MAX_BLOCKED_ENTRIES).flatMap((entry) => {
345
+ if (!entry || (entry.kind !== 'host' && entry.kind !== 'client')) return [];
346
+ if (!Number.isInteger(entry.count) || entry.count < 1 || typeof entry.value !== 'string' || !entry.value) return [];
347
+ return [{ kind: entry.kind, value: entry.value.slice(0, 255), count: Math.min(entry.count, 1e9) }];
348
+ });
349
+ }
350
+
351
+ // A release label, or null when absent or malformed (it never fails the record).
352
+ function release(value) {
353
+ return typeof value === 'string' && RELEASE.test(value) ? value : null;
354
+ }
355
+
356
+ function text(value, max) {
357
+ return typeof value === 'string' ? value.slice(0, max) : '';
358
+ }
359
+
360
+ function byteCount(value) {
361
+ return Number.isInteger(value) && value >= 0 ? value : 0;
362
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The local hub process: one per machine, shared by every app that uses PeekInside.
3
+ *
4
+ * Apps start it in the background when they need it (HubClient#spawn() runs entry.js), or
5
+ * people run `npx peekinside`. It stops by itself after an hour without apps or open
6
+ * dashboards; the data stays on disk.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { VERSION, settings } from '../config.js';
12
+ import { HubClient } from './client.js';
13
+ import { HubServer } from './server.js';
14
+ import { SpoolIngester } from './spool-ingester.js';
15
+ import { Store } from './store.js';
16
+
17
+ const IDLE_MS = 60 * 60 * 1000;
18
+ const PERSIST_MS = 60 * 1000;
19
+
20
+ /**
21
+ * Runs the hub in this process: loads the store, serves the API, ingests what crashed apps
22
+ * spooled, saves metadata every minute, and stops when idle or signalled.
23
+ *
24
+ * hub.json in the data folder says which process runs the hub, on which port, so a second hub
25
+ * never writes to the same folder.
26
+ */
27
+ export class HubProcess {
28
+ /**
29
+ * Nothing is read or started until run(), so failures surface from run() as rejections.
30
+ */
31
+ constructor() {
32
+ this.dataDir = null;
33
+ this.port = null;
34
+ this.infoFile = null;
35
+ this.store = null;
36
+ this.server = null;
37
+ this.spool = null;
38
+ this.lastActivity = 0;
39
+ this.stopping = false;
40
+ }
41
+
42
+ /**
43
+ * Starts the hub with the current settings. Exits the process when this hub can't run: with
44
+ * status 1 when another hub uses the data folder or another program holds the port, and with
45
+ * status 0 when a hub already serves the port.
46
+ *
47
+ * @returns {Promise<void>} Resolves once startup has begun; the hub keeps running afterwards
48
+ */
49
+ async run() {
50
+ this.dataDir = settings.dataDir();
51
+ this.port = settings.hubPort();
52
+ fs.mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
53
+ this.infoFile = path.join(this.dataDir, 'hub.json');
54
+
55
+ // Two hubs must never write to the same folder.
56
+ const other = this._readInfo();
57
+ if (other && other.pid !== process.pid && isRunning(other.pid) && other.port !== this.port) {
58
+ console.error(`${timestamp()} Another PeekInside hub (pid ${other.pid}) uses ${this.dataDir} on port ${other.port}. Stop it first (npx peekinside stop).`);
59
+ process.exit(1);
60
+ }
61
+
62
+ this.store = new Store(this.dataDir, { maxRequests: settings.maxRequests(), maxExceptions: settings.maxExceptions() }).load();
63
+ this.lastActivity = Date.now();
64
+ // Crashes recorded while no hub ran are waiting in the spool folder; more arrive while it runs.
65
+ // Ingesting writes to the store, so it starts once this hub owns the port: a hub that loses
66
+ // the race for it exits without touching the data.
67
+ this.spool = new SpoolIngester({
68
+ store: this.store,
69
+ dataDir: this.dataDir,
70
+ log: (message) => console.error(`${timestamp()} ${message}`),
71
+ onIngest: () => {
72
+ this.lastActivity = Date.now();
73
+ }
74
+ });
75
+ this.server = new HubServer({
76
+ store: this.store,
77
+ port: this.port,
78
+ siteUrl: settings.siteUrl(),
79
+ version: VERSION,
80
+ dataDir: this.dataDir,
81
+ onActivity: () => {
82
+ this.lastActivity = Date.now();
83
+ }
84
+ });
85
+
86
+ this.server.httpServer.on('error', (error) => this._onServerError(error));
87
+ this.server.listen(() => {
88
+ this._writeInfo({ pid: process.pid, port: this.port, version: VERSION, startedAt: Date.now() });
89
+ this.spool.start();
90
+ console.log(`${timestamp()} PeekInside Local ${VERSION} on ${settings.hubUrl(this.port)} · data in ${this.dataDir} · ${this.store.size} project(s)`);
91
+ });
92
+
93
+ setInterval(() => {
94
+ this._persist();
95
+ if (Date.now() - this.lastActivity > IDLE_MS) this.stop('idle for an hour');
96
+ }, PERSIST_MS);
97
+
98
+ process.on('SIGINT', () => this.stop('SIGINT'));
99
+ process.on('SIGTERM', () => this.stop('SIGTERM'));
100
+ process.on('SIGHUP', () => this.stop('SIGHUP'));
101
+ }
102
+
103
+ /**
104
+ * Saves metadata, releases hub.json and exits shortly after.
105
+ *
106
+ * @param {string} reason - Logged, such as "SIGTERM"
107
+ * @returns {void}
108
+ */
109
+ stop(reason) {
110
+ if (this.stopping) return;
111
+ this.stopping = true;
112
+ console.log(`${timestamp()} Stopping (${reason}).`);
113
+ this.spool.stop();
114
+ this._persist();
115
+ // Only the hub that wrote hub.json removes it.
116
+ const info = this._readInfo();
117
+ if (info && info.pid === process.pid) fs.rmSync(this.infoFile, { force: true });
118
+ this.server.close();
119
+ setTimeout(() => process.exit(0), 200).unref();
120
+ }
121
+
122
+ // The port is taken: by another hub (a race between two apps starting it), which is fine, or by
123
+ // another program, which isn't.
124
+ async _onServerError(error) {
125
+ if (error.code === 'EADDRINUSE') {
126
+ const running = await new HubClient(this.port).status().catch(() => null);
127
+ if (running) {
128
+ console.log(`${timestamp()} Already running on ${settings.hubUrl(this.port)} (pid ${running.pid}).`);
129
+ process.exit(0);
130
+ }
131
+ console.error(`${timestamp()} Port ${this.port} is used by another program. Set PEEKINSIDE_HUB_PORT to use a different port.`);
132
+ } else {
133
+ console.error(`${timestamp()} ${error.message}`);
134
+ }
135
+ process.exit(1);
136
+ }
137
+
138
+ _persist() {
139
+ try {
140
+ this.store.persist();
141
+ } catch (error) {
142
+ console.error(`${timestamp()} Could not save project data: ${error.message}`);
143
+ }
144
+ }
145
+
146
+ _readInfo() {
147
+ try {
148
+ const info = JSON.parse(fs.readFileSync(this.infoFile, 'utf8'));
149
+ return Number.isInteger(info.pid) ? info : null;
150
+ } catch (error) {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ // Written to a temporary file first, so readers never see half a file.
156
+ _writeInfo(info) {
157
+ const temp = `${this.infoFile}.${process.pid}.tmp`;
158
+ fs.writeFileSync(temp, `${JSON.stringify(info, null, 2)}\n`, { mode: 0o600 });
159
+ fs.renameSync(temp, this.infoFile);
160
+ }
161
+ }
162
+
163
+ // A process that exists but belongs to another user (EPERM) is running too.
164
+ function isRunning(pid) {
165
+ try {
166
+ process.kill(pid, 0);
167
+ return true;
168
+ } catch (error) {
169
+ return error.code === 'EPERM';
170
+ }
171
+ }
172
+
173
+ function timestamp() {
174
+ return new Date().toISOString();
175
+ }