peekinside 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +45 -2
- package/bin/peekinside.js +8 -0
- package/index.d.ts +389 -0
- package/index.js +3 -0
- package/package.json +77 -4
- package/src/PeekInside.js +731 -0
- package/src/capture/body-capture.js +86 -0
- package/src/capture/body-decoder.js +188 -0
- package/src/capture/exchange-recorder.js +263 -0
- package/src/capture/http-message.js +138 -0
- package/src/capture/ignored-paths.js +49 -0
- package/src/capture/request-body-tap.js +40 -0
- package/src/capture/response-tap.js +83 -0
- package/src/cli.js +254 -0
- package/src/config.js +183 -0
- package/src/crypto.js +189 -0
- package/src/deployment.js +183 -0
- package/src/exceptions/exception-builder.js +339 -0
- package/src/exceptions/exception-capture.js +407 -0
- package/src/exceptions/exception-record.js +118 -0
- package/src/exceptions/fingerprint.js +60 -0
- package/src/exceptions/frame-classifier.js +114 -0
- package/src/exceptions/limits.js +95 -0
- package/src/exceptions/process-monitor.js +192 -0
- package/src/exceptions/rate-limiter.js +50 -0
- package/src/exceptions/request-context.js +337 -0
- package/src/exceptions/safe-json.js +186 -0
- package/src/exceptions/source-context.js +144 -0
- package/src/exceptions/source-maps.js +313 -0
- package/src/exceptions/stack-parser.js +179 -0
- package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
- package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
- package/src/hub/client.js +184 -0
- package/src/hub/entry.js +4 -0
- package/src/hub/exception-state.js +97 -0
- package/src/hub/ingest-payload.js +362 -0
- package/src/hub/process.js +175 -0
- package/src/hub/project-log.js +559 -0
- package/src/hub/segment-log.js +282 -0
- package/src/hub/server.js +561 -0
- package/src/hub/spool-ingester.js +110 -0
- package/src/hub/store.js +404 -0
- package/src/instrumentation/console.js +83 -0
- package/src/instrumentation/http.js +146 -0
- package/src/instrumentation/index.js +234 -0
- package/src/instrumentation/jobs.js +186 -0
- package/src/instrumentation/loggers.js +135 -0
- package/src/instrumentation/mongodb.js +221 -0
- package/src/instrumentation/mysql2.js +161 -0
- package/src/instrumentation/operation.js +70 -0
- package/src/instrumentation/patch.js +72 -0
- package/src/instrumentation/pg.js +218 -0
- package/src/instrumentation/prisma.js +181 -0
- package/src/integrations/express-errors.js +135 -0
- package/src/integrations/express.js +57 -0
- package/src/integrations/fastify.js +72 -0
- package/src/integrations/framework-adapter.js +53 -0
- package/src/integrations/http-server.js +32 -0
- package/src/integrations/koa.js +72 -0
- package/src/integrations/server-binder.js +120 -0
- package/src/jobs/job-context.js +40 -0
- package/src/jobs/job-run-record.js +104 -0
- package/src/jobs/job-runner.js +173 -0
- package/src/logs/log-capture.js +143 -0
- package/src/logs/log-record.js +74 -0
- package/src/project.js +76 -0
- package/src/record.js +181 -0
- package/src/release.js +157 -0
- package/src/runtime/loop-monitor.js +162 -0
- package/src/runtime/sampler.js +93 -0
- package/src/spool.js +195 -0
- package/src/timeline/caller.js +129 -0
- package/src/timeline/execution-hash.js +110 -0
- package/src/timeline/lru.js +50 -0
- package/src/timeline/masking.js +266 -0
- package/src/timeline/shape.js +77 -0
- package/src/timeline/sql.js +396 -0
- package/src/timeline/timeline.js +642 -0
- package/src/transport/batch.js +324 -0
- package/src/transport/cloud.js +363 -0
- package/src/transport/local.js +354 -0
- package/src/user.js +83 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads V8 stack traces, the `error.stack` strings of Node.js, into frames.
|
|
3
|
+
*
|
|
4
|
+
* Every form V8 prints is understood:
|
|
5
|
+
*
|
|
6
|
+
* at createOrder (/app/src/orders.js:12:9) named function
|
|
7
|
+
* at /app/src/orders.js:12:9 anonymous function
|
|
8
|
+
* at async createOrder (/app/src/orders.js:12:9) async continuation
|
|
9
|
+
* at new Order (/app/src/order.js:3:11) constructor
|
|
10
|
+
* at Layer.handle [as handle_request] (...) method called under another name
|
|
11
|
+
* at file:///app/src/orders.mjs:12:9 ES module (file URL)
|
|
12
|
+
* at Module._compile (node:internal/modules/...) Node.js internals
|
|
13
|
+
* at JSON.parse (<anonymous>) built-in, no source
|
|
14
|
+
* at Array.forEach (native) built-in (older V8)
|
|
15
|
+
* at async Promise.all (index 0) Promise combinator
|
|
16
|
+
* at eval (eval at run (/app/src/x.js:3:5), <anonymous>:1:7) code run by eval()
|
|
17
|
+
*
|
|
18
|
+
* Parsing is purely textual: nothing here touches the file system.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
|
|
23
|
+
// Frames are indented: a line of the message that happens to start with "at " is not a frame.
|
|
24
|
+
const FRAME_LINE = /^\s+at (.+)$/;
|
|
25
|
+
// "file:line:column" or "file:line". The file part is matched lazily, so the line and column are
|
|
26
|
+
// the last two numbers even when the path itself contains colons (C:\..., node:internal/...).
|
|
27
|
+
const LOCATION = /^(.*?):(\d+)(?::(\d+))?$/;
|
|
28
|
+
const PROMISE_INDEX = /^index \d+$/;
|
|
29
|
+
// The header holds the error's name and message; frames start after it.
|
|
30
|
+
const MAX_HEADER_SEARCH = 1024;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} ParsedFrame
|
|
34
|
+
* @property {string|null} function - Such as "createOrder" or "Layer.handle [as handle_request]";
|
|
35
|
+
* null for anonymous functions
|
|
36
|
+
* @property {string|null} file - Path, "node:..." for internals, null when there is no source
|
|
37
|
+
* @property {number|null} line
|
|
38
|
+
* @property {number|null} column
|
|
39
|
+
* @property {boolean} native - A built-in without source ("native", "<anonymous>")
|
|
40
|
+
* @property {boolean} async - An "at async" frame
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Turns stack strings and their lines into frames.
|
|
45
|
+
*/
|
|
46
|
+
export class StackParser {
|
|
47
|
+
/**
|
|
48
|
+
* Parses a stack trace, top (throwing) frame first.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} stack - error.stack
|
|
51
|
+
* @param {Object} [options]
|
|
52
|
+
* @param {string} [options.message] - The error's message. Frames are read after it, so a
|
|
53
|
+
* message that quotes another stack trace isn't mistaken for frames.
|
|
54
|
+
* @param {number} [options.limit=100] - Most frames returned
|
|
55
|
+
* @returns {ParsedFrame[]}
|
|
56
|
+
*/
|
|
57
|
+
static parse(stack, { message = '', limit = 100 } = {}) {
|
|
58
|
+
if (typeof stack !== 'string' || stack === '') return [];
|
|
59
|
+
|
|
60
|
+
let text = stack;
|
|
61
|
+
if (typeof message === 'string' && message !== '') {
|
|
62
|
+
const at = stack.indexOf(message);
|
|
63
|
+
if (at !== -1 && at <= MAX_HEADER_SEARCH) text = stack.slice(at + message.length);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const frames = [];
|
|
67
|
+
for (const line of text.split('\n')) {
|
|
68
|
+
if (frames.length >= limit) break;
|
|
69
|
+
const match = FRAME_LINE.exec(line);
|
|
70
|
+
if (match) frames.push(StackParser.parseFrame(match[1]));
|
|
71
|
+
}
|
|
72
|
+
return frames;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parses what follows "at " on one frame line.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} body - Such as "async createOrder (/app/src/orders.js:12:9)"
|
|
79
|
+
* @returns {ParsedFrame}
|
|
80
|
+
*/
|
|
81
|
+
static parseFrame(body) {
|
|
82
|
+
let rest = String(body).trim();
|
|
83
|
+
let isAsync = false;
|
|
84
|
+
if (rest.startsWith('async ')) {
|
|
85
|
+
isAsync = true;
|
|
86
|
+
rest = rest.slice(6).trimStart();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let fn = null;
|
|
90
|
+
let location = rest;
|
|
91
|
+
// "name (location)": the location is the last parenthesized group, which may itself contain
|
|
92
|
+
// parentheses (eval frames, folder names such as "My App (old)").
|
|
93
|
+
if (rest.endsWith(')')) {
|
|
94
|
+
const open = matchingOpen(rest, rest.length - 1);
|
|
95
|
+
if (open > 0 && rest[open - 1] === ' ') {
|
|
96
|
+
fn = rest.slice(0, open - 1).trim() || null;
|
|
97
|
+
location = rest.slice(open + 1, -1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { function: fn, ...StackParser.parseLocation(location), async: isAsync };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parses a frame's location.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} location - Such as "/app/src/orders.js:12:9", "native" or "eval at ..."
|
|
108
|
+
* @returns {{ file: string|null, line: number|null, column: number|null, native: boolean }}
|
|
109
|
+
*/
|
|
110
|
+
static parseLocation(location) {
|
|
111
|
+
const text = String(location).trim();
|
|
112
|
+
if (text === 'native' || text === '<anonymous>' || text === 'unknown location' || PROMISE_INDEX.test(text)) {
|
|
113
|
+
return { file: null, line: null, column: null, native: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Code run by eval() or new Function() has no file of its own: the place that called eval()
|
|
117
|
+
// is what tells people where it came from.
|
|
118
|
+
if (text.startsWith('eval at ')) {
|
|
119
|
+
const origin = evalOrigin(text);
|
|
120
|
+
return origin ? { ...StackParser.parseLocation(origin), native: false } : { file: null, line: null, column: null, native: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const match = LOCATION.exec(text);
|
|
124
|
+
if (match) {
|
|
125
|
+
const line = Number(match[2]);
|
|
126
|
+
const column = match[3] === undefined ? null : Number(match[3]);
|
|
127
|
+
// Generated code without a name ("<anonymous>:3:9") has positions but no file.
|
|
128
|
+
if (match[1] === '<anonymous>') return { file: null, line, column, native: true };
|
|
129
|
+
return { file: toPath(match[1]), line, column, native: false };
|
|
130
|
+
}
|
|
131
|
+
return { file: text ? toPath(text) : null, line: null, column: null, native: false };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The index of the "(" that the ")" at `close` closes, or -1.
|
|
136
|
+
function matchingOpen(text, close) {
|
|
137
|
+
let depth = 0;
|
|
138
|
+
for (let i = close; i >= 0; i--) {
|
|
139
|
+
const ch = text[i];
|
|
140
|
+
if (ch === ')') depth++;
|
|
141
|
+
else if (ch === '(' && --depth === 0) return i;
|
|
142
|
+
}
|
|
143
|
+
return -1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The index of the ")" that closes the "(" at `open`, or -1.
|
|
147
|
+
function matchingClose(text, open) {
|
|
148
|
+
let depth = 0;
|
|
149
|
+
for (let i = open; i < text.length; i++) {
|
|
150
|
+
const ch = text[i];
|
|
151
|
+
if (ch === '(') depth++;
|
|
152
|
+
else if (ch === ')' && --depth === 0) return i;
|
|
153
|
+
}
|
|
154
|
+
return -1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// "eval at run (/app/x.js:3:5), <anonymous>:1:7" -> "/app/x.js:3:5". Nested evals
|
|
158
|
+
// ("eval at a (eval at b (/app/x.js:1:2), <anonymous>:3:4), ...") lead to the outermost caller.
|
|
159
|
+
function evalOrigin(text) {
|
|
160
|
+
let current = text;
|
|
161
|
+
for (let depth = 0; depth < 10 && current.startsWith('eval at '); depth++) {
|
|
162
|
+
const open = current.indexOf('(');
|
|
163
|
+
if (open === -1) return null;
|
|
164
|
+
const close = matchingClose(current, open);
|
|
165
|
+
if (close === -1) return null;
|
|
166
|
+
current = current.slice(open + 1, close).trim();
|
|
167
|
+
}
|
|
168
|
+
return current.startsWith('eval at ') ? null : current;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ES modules report file URLs; everything else keeps its form ("node:fs", "webpack://...").
|
|
172
|
+
function toPath(file) {
|
|
173
|
+
if (!file.startsWith('file://')) return file;
|
|
174
|
+
try {
|
|
175
|
+
return fileURLToPath(file);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return file;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g id="Stacked mark / 48">
|
|
3
|
+
<path id="Parent request span" d="M3.75 9.75H30.75C33.1369 9.75 35.4261 10.6982 37.114 12.386C38.8018 14.0739 39.75 16.3631 39.75 18.75V29.25C39.75 31.6369 38.8018 33.9261 37.114 35.614C35.4261 37.3018 33.1369 38.25 30.75 38.25H44.25" stroke="#EEF0EC" stroke-width="5" stroke-linecap="square" stroke-linejoin="round"/>
|
|
4
|
+
<path id="Child execution span" d="M10.5 17.25H28.5C29.6935 17.25 30.8381 17.7241 31.682 18.568C32.5259 19.4119 33 20.5565 33 21.75V26.25C33 27.4435 32.5259 28.5881 31.682 29.432C30.8381 30.2759 29.6935 30.75 28.5 30.75H21.75" stroke="#EEF0EC" stroke-width="4" stroke-linecap="square" stroke-linejoin="round"/>
|
|
5
|
+
<path id="Measured core" d="M17.25 24H27" stroke="#46C5AF" stroke-width="4" stroke-linecap="square" stroke-linejoin="round"/>
|
|
6
|
+
</g>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g id="Light mode glyph">
|
|
3
|
+
<path id="Parent request span" d="M6.875 17.875H56.375C60.7511 17.875 64.9479 19.6134 68.0423 22.7077C71.1366 25.8021 72.875 29.9989 72.875 34.375V53.625C72.875 58.0011 71.1366 62.1979 68.0423 65.2923C64.9479 68.3866 60.7511 70.125 56.375 70.125H81.125" stroke="#121A10" stroke-width="5" stroke-linecap="square" stroke-linejoin="round"/>
|
|
4
|
+
<path id="Child execution span" d="M19.25 31.625H52.25C54.438 31.625 56.5365 32.4942 58.0836 34.0414C59.6308 35.5885 60.5 37.687 60.5 39.875V48.125C60.5 50.313 59.6308 52.4115 58.0836 53.9586C56.5365 55.5058 54.438 56.375 52.25 56.375H39.875" stroke="#121A10" stroke-width="4" stroke-linecap="square" stroke-linejoin="round"/>
|
|
5
|
+
<path id="Measured core" d="M31.625 44H49.5" stroke="#18B69B" stroke-width="4" stroke-linecap="square" stroke-linejoin="round"/>
|
|
6
|
+
</g>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talking to the local hub from apps and the CLI: health checks, starting it in the background
|
|
3
|
+
* and sending JSON. Connects to 127.0.0.1 directly, so "localhost" resolving to IPv6 first
|
|
4
|
+
* never matters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import http from 'http';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { HUB_APP_ID, VERSION, settings } from '../config.js';
|
|
13
|
+
|
|
14
|
+
const HUB_ENTRY = fileURLToPath(new URL('./entry.js', import.meta.url));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A failed hub request.
|
|
18
|
+
*
|
|
19
|
+
* `code` tells failures apart: a Node network code such as ECONNREFUSED (nothing listens),
|
|
20
|
+
* HUB_ERROR (the hub answered with an error) or NOT_HUB (another program answered).
|
|
21
|
+
*/
|
|
22
|
+
export class HubError extends Error {
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} message
|
|
25
|
+
* @param {Object} [details]
|
|
26
|
+
* @param {number} [details.status=0] - HTTP status, when there was an answer
|
|
27
|
+
* @param {string} [details.code=''] - Failure code
|
|
28
|
+
*/
|
|
29
|
+
constructor(message, { status = 0, code = '' } = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Client of the hub on one port.
|
|
38
|
+
*/
|
|
39
|
+
export class HubClient {
|
|
40
|
+
/**
|
|
41
|
+
* @param {number} [port] - Hub port (default: PEEKINSIDE_HUB_PORT or 7335)
|
|
42
|
+
* @param {Object} [options]
|
|
43
|
+
* @param {boolean} [options.keepAlive=false] - Reuse connections, for apps that send batches
|
|
44
|
+
* all day long. Call close() when done.
|
|
45
|
+
*/
|
|
46
|
+
constructor(port = settings.hubPort(), { keepAlive = false } = {}) {
|
|
47
|
+
this.port = port;
|
|
48
|
+
// Batches are sent one at a time, so two sockets are plenty.
|
|
49
|
+
this.agent = keepAlive ? new http.Agent({ keepAlive: true, maxSockets: 2 }) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Address of the hub's dashboard, as shown to people.
|
|
54
|
+
*
|
|
55
|
+
* @type {string}
|
|
56
|
+
*/
|
|
57
|
+
get url() {
|
|
58
|
+
return settings.hubUrl(this.port);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sends a JSON request to the hub.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} method
|
|
65
|
+
* @param {string} pathname
|
|
66
|
+
* @param {Object} [options]
|
|
67
|
+
* @param {*} [options.body] - Sent as JSON
|
|
68
|
+
* @param {number} [options.timeout=5000] - Milliseconds without activity before giving up
|
|
69
|
+
* @returns {Promise<Object>} Parsed response body; rejects with HubError for non-2xx answers,
|
|
70
|
+
* answers that are not JSON (code NOT_HUB) and network errors
|
|
71
|
+
*/
|
|
72
|
+
request(method, pathname, { body, timeout = 5000 } = {}) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
75
|
+
// The hub accepts ingest only with this header, which browsers can't send cross-site
|
|
76
|
+
// without a preflight the hub never answers.
|
|
77
|
+
const headers = { 'x-peekinside-sdk': VERSION || 'dev', accept: 'application/json' };
|
|
78
|
+
if (payload !== null) {
|
|
79
|
+
headers['content-type'] = 'application/json';
|
|
80
|
+
headers['content-length'] = Buffer.byteLength(payload);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const req = http.request({ host: '127.0.0.1', port: this.port, method, path: pathname, headers, agent: this.agent, timeout }, (res) => {
|
|
84
|
+
const chunks = [];
|
|
85
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
86
|
+
res.on('end', () => {
|
|
87
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
88
|
+
let parsed = null;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(text);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
parsed = null;
|
|
93
|
+
}
|
|
94
|
+
if (res.statusCode >= 200 && res.statusCode < 300 && parsed) return resolve(parsed);
|
|
95
|
+
const message = (parsed && parsed.error) || `HTTP ${res.statusCode}`;
|
|
96
|
+
reject(new HubError(message, { status: res.statusCode, code: parsed ? 'HUB_ERROR' : 'NOT_HUB' }));
|
|
97
|
+
});
|
|
98
|
+
res.on('error', (error) => reject(new HubError(error.message, { code: error.code })));
|
|
99
|
+
});
|
|
100
|
+
req.on('timeout', () => req.destroy(Object.assign(new Error('Timed out'), { code: 'ETIMEDOUT' })));
|
|
101
|
+
req.on('error', (error) => reject(new HubError(error.message, { code: error.code })));
|
|
102
|
+
req.end(payload === null ? undefined : payload);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Checks whether the hub runs.
|
|
108
|
+
*
|
|
109
|
+
* @param {number} [timeout=2000]
|
|
110
|
+
* @returns {Promise<Object|null>} The hub's status, or null when nothing listens on the port.
|
|
111
|
+
* Rejects with code NOT_HUB when another program uses the port.
|
|
112
|
+
*/
|
|
113
|
+
async status(timeout = 2000) {
|
|
114
|
+
try {
|
|
115
|
+
const status = await this.request('GET', '/api/status', { timeout });
|
|
116
|
+
if (status.app !== HUB_APP_ID) throw new HubError('Another program uses this port', { code: 'NOT_HUB' });
|
|
117
|
+
return status;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error.code === 'ECONNREFUSED') return null;
|
|
120
|
+
if (error.code === 'HUB_ERROR' || error.code === 'NOT_HUB') {
|
|
121
|
+
throw new HubError(`Port ${this.port} is used by another program`, { code: 'NOT_HUB' });
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Starts the hub as a background process that outlives this one. Its output goes to hub.log
|
|
129
|
+
* in the data folder.
|
|
130
|
+
*
|
|
131
|
+
* @returns {void}
|
|
132
|
+
*/
|
|
133
|
+
spawn() {
|
|
134
|
+
const dir = settings.dataDir();
|
|
135
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
136
|
+
const log = fs.openSync(path.join(dir, 'hub.log'), 'a', 0o600);
|
|
137
|
+
|
|
138
|
+
// NODE_OPTIONS may hold loaders or --inspect flags meant for the app, not for the hub.
|
|
139
|
+
const env = { ...process.env, PEEKINSIDE_HUB_PORT: String(this.port) };
|
|
140
|
+
delete env.NODE_OPTIONS;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const child = spawn(process.execPath, [HUB_ENTRY], {
|
|
144
|
+
detached: true,
|
|
145
|
+
stdio: ['ignore', log, log],
|
|
146
|
+
env,
|
|
147
|
+
windowsHide: true
|
|
148
|
+
});
|
|
149
|
+
child.on('error', () => {});
|
|
150
|
+
child.unref();
|
|
151
|
+
} finally {
|
|
152
|
+
fs.closeSync(log);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Waits until the hub answers.
|
|
158
|
+
*
|
|
159
|
+
* @param {number} [timeout=5000] - Milliseconds to wait
|
|
160
|
+
* @returns {Promise<Object|null>} The hub's status, or null when it did not answer in time.
|
|
161
|
+
* Rejects with code NOT_HUB when another program uses the port.
|
|
162
|
+
*/
|
|
163
|
+
async waitUntilReady(timeout = 5000) {
|
|
164
|
+
const deadline = Date.now() + timeout;
|
|
165
|
+
for (;;) {
|
|
166
|
+
const status = await this.status().catch((error) => {
|
|
167
|
+
if (error.code === 'NOT_HUB') throw error;
|
|
168
|
+
return null;
|
|
169
|
+
});
|
|
170
|
+
if (status) return status;
|
|
171
|
+
if (Date.now() > deadline) return null;
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Closes kept-alive connections.
|
|
178
|
+
*
|
|
179
|
+
* @returns {void}
|
|
180
|
+
*/
|
|
181
|
+
close() {
|
|
182
|
+
if (this.agent) this.agent.destroy();
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/hub/entry.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the dashboard remembers about a project's issues: which exception fingerprints were marked
|
|
3
|
+
* resolved, and when. Stored in the project's folder as exception-state.json.
|
|
4
|
+
*
|
|
5
|
+
* Several dashboards (tabs) may edit it at once, so every save names the version it started from;
|
|
6
|
+
* a save based on an older version is refused, and the dashboard merges and tries again.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
const FINGERPRINT = /^[0-9a-f]{16}$/;
|
|
13
|
+
export const MAX_RESOLVED = 5000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} ExceptionStateValue
|
|
17
|
+
* @property {Object<string, number>} resolved - Fingerprint -> when it was marked resolved (ms)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The exception state file of one project.
|
|
22
|
+
*/
|
|
23
|
+
export class ExceptionState {
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} dir - The project's folder
|
|
26
|
+
*/
|
|
27
|
+
constructor(dir) {
|
|
28
|
+
this.file = path.join(dir, 'exception-state.json');
|
|
29
|
+
this._current = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Checks a state sent by a dashboard.
|
|
34
|
+
*
|
|
35
|
+
* @param {*} value
|
|
36
|
+
* @returns {ExceptionStateValue|null} The state with only known fields, or null when it is
|
|
37
|
+
* malformed or holds more than 5000 fingerprints
|
|
38
|
+
*/
|
|
39
|
+
static validate(value) {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
41
|
+
const resolved = value.resolved;
|
|
42
|
+
if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) return null;
|
|
43
|
+
const keys = Object.keys(resolved);
|
|
44
|
+
if (keys.length > MAX_RESOLVED) return null;
|
|
45
|
+
|
|
46
|
+
const clean = {};
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const at = resolved[key];
|
|
49
|
+
if (!FINGERPRINT.test(key) || !Number.isFinite(at) || at < 0) return null;
|
|
50
|
+
clean[key] = at;
|
|
51
|
+
}
|
|
52
|
+
return { resolved: clean };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The saved state; version 0 and nothing resolved when it was never saved.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{ state: ExceptionStateValue, version: number }}
|
|
59
|
+
*/
|
|
60
|
+
read() {
|
|
61
|
+
if (!this._current) {
|
|
62
|
+
this._current = { state: { resolved: {} }, version: 0 };
|
|
63
|
+
try {
|
|
64
|
+
const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
65
|
+
const state = ExceptionState.validate(saved && saved.state);
|
|
66
|
+
if (state && Number.isInteger(saved.version) && saved.version > 0) {
|
|
67
|
+
this._current = { state, version: saved.version };
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// Missing or unreadable: start from scratch rather than fail the dashboard.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return this._current;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Saves a new state if `version` is the current one.
|
|
78
|
+
*
|
|
79
|
+
* @param {ExceptionStateValue} state - Validated state
|
|
80
|
+
* @param {number} version - The version the dashboard read
|
|
81
|
+
* @returns {{ saved: boolean, state: ExceptionStateValue, version: number }} The new version when
|
|
82
|
+
* saved; otherwise the current state and version, for the dashboard to merge with
|
|
83
|
+
*/
|
|
84
|
+
write(state, version) {
|
|
85
|
+
const current = this.read();
|
|
86
|
+
if (version !== current.version) {
|
|
87
|
+
return { saved: false, ...current };
|
|
88
|
+
}
|
|
89
|
+
const next = { state, version: current.version + 1 };
|
|
90
|
+
// Written to a temporary file first, so a crash never leaves half a file.
|
|
91
|
+
const temp = `${this.file}.${process.pid}.tmp`;
|
|
92
|
+
fs.writeFileSync(temp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
93
|
+
fs.renameSync(temp, this.file);
|
|
94
|
+
this._current = next;
|
|
95
|
+
return { saved: true, ...next };
|
|
96
|
+
}
|
|
97
|
+
}
|