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.
- 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 +538 -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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON for values the app hands over without knowing their size or shape: report()'s `extra`, and
|
|
3
|
+
* thrown values that aren't errors.
|
|
4
|
+
*
|
|
5
|
+
* JSON.stringify would throw on cycles and BigInts, run getters that throw, and serialize a huge
|
|
6
|
+
* object in full before anything could be cut. This writer stops once it has produced enough
|
|
7
|
+
* text, marks cycles, and skips what can't be read, so it never throws and never does more work
|
|
8
|
+
* than the limit allows.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MAX_DEPTH = 20;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bounded, never-throwing JSON serialization.
|
|
15
|
+
*/
|
|
16
|
+
export class SafeJson {
|
|
17
|
+
/**
|
|
18
|
+
* Serializes a value, stopping after `maxLength` characters.
|
|
19
|
+
*
|
|
20
|
+
* @param {*} value
|
|
21
|
+
* @param {number} maxLength - Characters produced at most (the result can be cut mid-value)
|
|
22
|
+
* @returns {{ json: string|undefined, truncated: boolean }} `json` is undefined for values JSON
|
|
23
|
+
* has no form for (undefined, functions, symbols); when `truncated`, it is the cut text, not
|
|
24
|
+
* valid JSON
|
|
25
|
+
*/
|
|
26
|
+
static stringify(value, maxLength) {
|
|
27
|
+
try {
|
|
28
|
+
const writer = new Writer(maxLength);
|
|
29
|
+
const written = writer.value(value, []);
|
|
30
|
+
if (!written) return { json: undefined, truncated: false };
|
|
31
|
+
return { json: writer.text(), truncated: writer.full };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// Every read is guarded already; this is the last line of defense.
|
|
34
|
+
return { json: '"[unserializable]"', truncated: false };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Writes JSON text into a bounded buffer. Once full, everything else is skipped, so the work
|
|
41
|
+
* stops where the output does.
|
|
42
|
+
*/
|
|
43
|
+
class Writer {
|
|
44
|
+
/**
|
|
45
|
+
* @param {number} maxLength - Characters kept at most
|
|
46
|
+
*/
|
|
47
|
+
constructor(maxLength) {
|
|
48
|
+
this.maxLength = maxLength;
|
|
49
|
+
this.parts = [];
|
|
50
|
+
this.length = 0;
|
|
51
|
+
this.full = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
text() {
|
|
55
|
+
return this.parts.join('').slice(0, this.maxLength);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
push(text) {
|
|
59
|
+
if (this.full) return;
|
|
60
|
+
this.parts.push(text);
|
|
61
|
+
this.length += text.length;
|
|
62
|
+
if (this.length > this.maxLength) this.full = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Writes one value; returns false when it has no JSON form (and nothing was written).
|
|
66
|
+
value(value, ancestors) {
|
|
67
|
+
if (this.full) return true;
|
|
68
|
+
let current = value;
|
|
69
|
+
|
|
70
|
+
if (current !== null && typeof current === 'object' && typeof safeGet(current, 'toJSON') === 'function') {
|
|
71
|
+
try {
|
|
72
|
+
current = current.toJSON();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
current = String(safeGet(current, 'message') ?? '[unserializable]');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch (typeof current) {
|
|
79
|
+
case 'string':
|
|
80
|
+
this.push(JSON.stringify(current));
|
|
81
|
+
return true;
|
|
82
|
+
case 'number':
|
|
83
|
+
this.push(Number.isFinite(current) ? String(current) : 'null');
|
|
84
|
+
return true;
|
|
85
|
+
case 'boolean':
|
|
86
|
+
this.push(current ? 'true' : 'false');
|
|
87
|
+
return true;
|
|
88
|
+
case 'bigint':
|
|
89
|
+
this.push(JSON.stringify(current.toString()));
|
|
90
|
+
return true;
|
|
91
|
+
case 'undefined':
|
|
92
|
+
case 'function':
|
|
93
|
+
case 'symbol':
|
|
94
|
+
return false;
|
|
95
|
+
default:
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (current === null) {
|
|
100
|
+
this.push('null');
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (ancestors.includes(current)) {
|
|
104
|
+
this.push('"[Circular]"');
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (ancestors.length >= MAX_DEPTH) {
|
|
108
|
+
this.push('"[Too deep]"');
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
ancestors.push(current);
|
|
113
|
+
try {
|
|
114
|
+
if (Array.isArray(current)) this.array(current, ancestors);
|
|
115
|
+
else this.object(current, ancestors);
|
|
116
|
+
} finally {
|
|
117
|
+
ancestors.pop();
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
array(items, ancestors) {
|
|
123
|
+
this.push('[');
|
|
124
|
+
for (let i = 0; i < items.length && !this.full; i++) {
|
|
125
|
+
if (i > 0) this.push(',');
|
|
126
|
+
let item;
|
|
127
|
+
try {
|
|
128
|
+
item = items[i];
|
|
129
|
+
} catch (error) {
|
|
130
|
+
item = undefined;
|
|
131
|
+
}
|
|
132
|
+
if (!this.value(item, ancestors)) this.push('null');
|
|
133
|
+
}
|
|
134
|
+
this.push(']');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
object(source, ancestors) {
|
|
138
|
+
// Errors keep their name, message and stack, which JSON.stringify would drop (they aren't
|
|
139
|
+
// enumerable).
|
|
140
|
+
const isError = isNativeError(source);
|
|
141
|
+
let keys;
|
|
142
|
+
try {
|
|
143
|
+
keys = Object.keys(source);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
keys = [];
|
|
146
|
+
}
|
|
147
|
+
if (isError) keys = ['name', 'message', 'stack', ...keys.filter((key) => key !== 'name' && key !== 'message' && key !== 'stack')];
|
|
148
|
+
|
|
149
|
+
this.push('{');
|
|
150
|
+
let first = true;
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
if (this.full) break;
|
|
153
|
+
const item = safeGet(source, key);
|
|
154
|
+
const mark = this.parts.length;
|
|
155
|
+
const length = this.length;
|
|
156
|
+
this.push(`${first ? '' : ','}${JSON.stringify(key)}:`);
|
|
157
|
+
if (this.value(item, ancestors)) {
|
|
158
|
+
first = false;
|
|
159
|
+
} else {
|
|
160
|
+
// No JSON form: drop the key again, as JSON.stringify does.
|
|
161
|
+
this.parts.length = mark;
|
|
162
|
+
this.length = length;
|
|
163
|
+
this.full = this.length > this.maxLength;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.push('}');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// instanceof runs the getPrototypeOf trap of a proxy, which may throw.
|
|
171
|
+
function isNativeError(value) {
|
|
172
|
+
try {
|
|
173
|
+
return value instanceof Error;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reads a property, treating getters that throw (and revoked proxies) as missing.
|
|
180
|
+
function safeGet(target, key) {
|
|
181
|
+
try {
|
|
182
|
+
return target[key];
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The source lines around in-app frames, read from disk when an exception is recorded.
|
|
3
|
+
*
|
|
4
|
+
* Reads are synchronous: exceptions are also recorded while the process is crashing, when nothing
|
|
5
|
+
* asynchronous runs anymore. They stay cheap and safe: only source files of the app itself (inside
|
|
6
|
+
* the project folder, outside node_modules, after resolving symlinks), at most 1 MB each, and a
|
|
7
|
+
* few recently read files are kept in memory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { LIMITS, truncate } from './limits.js';
|
|
13
|
+
import { contextFrom } from './source-maps.js';
|
|
14
|
+
|
|
15
|
+
const SOURCE_FILE = /\.(?:js|mjs|cjs|ts|mts|cts|jsx|tsx)$/i;
|
|
16
|
+
const MAX_FILE_SIZE = 1024 * 1024;
|
|
17
|
+
const MAX_CACHED_FILES = 20;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads source lines of the app's files, with a small LRU cache.
|
|
21
|
+
*/
|
|
22
|
+
export class SourceContext {
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} root - The project folder; nothing outside it is read
|
|
25
|
+
* @param {Object} [options]
|
|
26
|
+
* @param {number} [options.maxFiles=20] - Files kept in memory
|
|
27
|
+
* @param {number} [options.maxFileSize=1048576] - Larger files are never read
|
|
28
|
+
*/
|
|
29
|
+
constructor(root, { maxFiles = MAX_CACHED_FILES, maxFileSize = MAX_FILE_SIZE } = {}) {
|
|
30
|
+
this.root = path.resolve(root || process.cwd());
|
|
31
|
+
this.maxFiles = maxFiles;
|
|
32
|
+
this.maxFileSize = maxFileSize;
|
|
33
|
+
// file -> { mtimeMs, size, lines (null when the file can't be shown) }, least recent first.
|
|
34
|
+
this._cache = new Map();
|
|
35
|
+
this._realRoot = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Adds source lines to the first in-app frames.
|
|
40
|
+
*
|
|
41
|
+
* @param {import('./frame-classifier.js').Frame[]} frames - Changed in place
|
|
42
|
+
* @param {number} [count=5] - In-app frames that get lines
|
|
43
|
+
* @param {(frame: Object) => string[]|null} [linesOf] - A frame's lines when they are already
|
|
44
|
+
* known (a source map that carries its sources); others are read from disk
|
|
45
|
+
* @returns {void}
|
|
46
|
+
*/
|
|
47
|
+
addTo(frames, count = LIMITS.contextFrames, linesOf = null) {
|
|
48
|
+
let remaining = count;
|
|
49
|
+
for (const frame of frames) {
|
|
50
|
+
if (remaining === 0) break;
|
|
51
|
+
if (!frame.inApp) continue;
|
|
52
|
+
remaining--;
|
|
53
|
+
try {
|
|
54
|
+
const known = linesOf ? linesOf(frame) : null;
|
|
55
|
+
frame.context = known ? contextFrom(known, frame.line) : this.around(frame.file, frame.line);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
frame.context = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The lines around one line of a file.
|
|
64
|
+
*
|
|
65
|
+
* @param {string|null} file - Absolute path
|
|
66
|
+
* @param {number|null} line - 1-based
|
|
67
|
+
* @param {number} [radius=5] - Lines on each side
|
|
68
|
+
* @returns {{ start: number, lines: string[] }|null} `start` is the number of the first line;
|
|
69
|
+
* null when the file can't be shown
|
|
70
|
+
*/
|
|
71
|
+
around(file, line, radius = LIMITS.contextLines) {
|
|
72
|
+
if (!file || !Number.isInteger(line) || line < 1 || !SOURCE_FILE.test(file)) return null;
|
|
73
|
+
const lines = this._lines(file);
|
|
74
|
+
if (!lines || line > lines.length) return null;
|
|
75
|
+
|
|
76
|
+
const start = Math.max(1, line - radius);
|
|
77
|
+
const end = Math.min(lines.length, line + radius);
|
|
78
|
+
return {
|
|
79
|
+
start,
|
|
80
|
+
lines: lines.slice(start - 1, end).map((text) => truncate(text, LIMITS.contextLineLength))
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The file's lines, from the cache while the file is unchanged. Checked on every call, so an
|
|
85
|
+
// edited file (a dev server that doesn't restart) shows its current lines.
|
|
86
|
+
_lines(file) {
|
|
87
|
+
let stat;
|
|
88
|
+
try {
|
|
89
|
+
stat = fs.statSync(file);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cached = this._cache.get(file);
|
|
95
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
96
|
+
this._cache.delete(file);
|
|
97
|
+
this._cache.set(file, cached);
|
|
98
|
+
return cached.lines;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let lines = null;
|
|
102
|
+
if (stat.isFile() && stat.size <= this.maxFileSize && this._isAppFile(file)) {
|
|
103
|
+
try {
|
|
104
|
+
lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
lines = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this._cache.delete(file);
|
|
111
|
+
this._cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, lines });
|
|
112
|
+
while (this._cache.size > this.maxFiles) {
|
|
113
|
+
this._cache.delete(this._cache.keys().next().value);
|
|
114
|
+
}
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Inside the project folder and not under node_modules, once symlinks are resolved: a link in
|
|
119
|
+
// the project must not expose a file elsewhere on the machine.
|
|
120
|
+
_isAppFile(file) {
|
|
121
|
+
let real;
|
|
122
|
+
try {
|
|
123
|
+
real = fs.realpathSync(file);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const relative = path.relative(this._root(), real);
|
|
128
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return !relative.split(path.sep).includes('node_modules');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_root() {
|
|
135
|
+
if (this._realRoot === null) {
|
|
136
|
+
try {
|
|
137
|
+
this._realRoot = fs.realpathSync(this.root);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this._realRoot = this.root;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return this._realRoot;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps the stack frames of compiled code (TypeScript output, bundles) back to the files people
|
|
3
|
+
* edit, with the source maps their build wrote: a `.map` file named by a sourceMappingURL comment,
|
|
4
|
+
* a map inlined in that comment, or `<file>.js.map` next to the file ("hidden" source maps).
|
|
5
|
+
*
|
|
6
|
+
* Like SourceContext, reads are synchronous (crashes are recorded too) and bounded: only the app's
|
|
7
|
+
* own compiled files are mapped (inside the project folder, outside node_modules), maps are read
|
|
8
|
+
* from the project folder only, at most 16 MB each, and a few are kept in memory. Node's own
|
|
9
|
+
* parser (module.SourceMap) reads them. Apps that run with --enable-source-maps, tsx or ts-node
|
|
10
|
+
* already have mapped stacks: their frames point at .ts files and are left alone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import { SourceMap } from 'module';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import { LIMITS, truncate } from './limits.js';
|
|
18
|
+
|
|
19
|
+
const GENERATED_FILE = /\.(?:js|mjs|cjs)$/i;
|
|
20
|
+
// The end of a compiled file holds its sourceMappingURL comment; an inline map can be large.
|
|
21
|
+
const MAX_TAIL = 8 * 1024 * 1024;
|
|
22
|
+
const MAX_MAP_SIZE = 16 * 1024 * 1024;
|
|
23
|
+
const MAX_CACHED_FILES = 8;
|
|
24
|
+
const COMMENT = /\/\/[#@][ \t]*sourceMappingURL=([^\s'"]+)[ \t]*$/;
|
|
25
|
+
const DATA_URL = /^data:application\/json(?:;charset=[\w-]+)?(;base64)?,(.*)$/i;
|
|
26
|
+
// Bundler URLs for sources: webpack://app/./src/x.ts, turbopack:///[project]/src/x.ts
|
|
27
|
+
const BUNDLER_URL = /^[a-z][a-z0-9+.-]*:\/\/[^/]*\/(?:\[project\]\/)?/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} LoadedMap
|
|
31
|
+
* @property {SourceMap} map
|
|
32
|
+
* @property {string} dir - Where relative sources are resolved from
|
|
33
|
+
* @property {string} sourceRoot
|
|
34
|
+
* @property {Map<string, number>} indexOf - Source as written in the map -> its index
|
|
35
|
+
* @property {Array<string|null>} contents - sourcesContent
|
|
36
|
+
* @property {Map<number, string[]>} lines - Split contents, by source index
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Applies source maps to frames, with a small LRU cache of parsed maps.
|
|
41
|
+
*/
|
|
42
|
+
export class SourceMaps {
|
|
43
|
+
/**
|
|
44
|
+
* @param {import('./frame-classifier.js').FrameClassifier} classifier - Classifies mapped frames
|
|
45
|
+
* @param {Object} [options]
|
|
46
|
+
* @param {number} [options.maxFiles=8] - Compiled files whose maps are kept in memory
|
|
47
|
+
* @param {number} [options.maxMapSize=16777216] - Larger maps are never read
|
|
48
|
+
*/
|
|
49
|
+
constructor(classifier, { maxFiles = MAX_CACHED_FILES, maxMapSize = MAX_MAP_SIZE } = {}) {
|
|
50
|
+
this.classifier = classifier;
|
|
51
|
+
this.maxFiles = maxFiles;
|
|
52
|
+
this.maxMapSize = maxMapSize;
|
|
53
|
+
// compiled file -> { mtimeMs, size, loaded (null without a usable map) }, least recent first
|
|
54
|
+
this._cache = new Map();
|
|
55
|
+
// Mapped frame -> the lines of its original file, when the map carries them (sourcesContent)
|
|
56
|
+
this._sourceLines = new WeakMap();
|
|
57
|
+
this._realRoot = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Maps frames of the app's compiled files to their sources, in place. A mapped frame keeps
|
|
62
|
+
* where it ran in `generated` and is classified again (a bundle can hold node_modules code).
|
|
63
|
+
*
|
|
64
|
+
* @param {import('./frame-classifier.js').Frame[]} frames
|
|
65
|
+
* @returns {import('./frame-classifier.js').Frame[]} The same array
|
|
66
|
+
*/
|
|
67
|
+
apply(frames) {
|
|
68
|
+
for (let index = 0; index < frames.length; index++) {
|
|
69
|
+
const frame = frames[index];
|
|
70
|
+
if (!frame.inApp || !frame.file || !GENERATED_FILE.test(frame.file) || !Number.isInteger(frame.line)) continue;
|
|
71
|
+
let mapped = null;
|
|
72
|
+
try {
|
|
73
|
+
mapped = this._map(frame);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
mapped = null;
|
|
76
|
+
}
|
|
77
|
+
if (mapped) frames[index] = mapped;
|
|
78
|
+
}
|
|
79
|
+
return frames;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The lines of a mapped frame's original file, when its map carried them.
|
|
84
|
+
*
|
|
85
|
+
* @param {import('./frame-classifier.js').Frame} frame
|
|
86
|
+
* @returns {string[]|null}
|
|
87
|
+
*/
|
|
88
|
+
linesOf(frame) {
|
|
89
|
+
return this._sourceLines.get(frame) || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_map(frame) {
|
|
93
|
+
const loaded = this._load(frame.file);
|
|
94
|
+
if (!loaded) return null;
|
|
95
|
+
const line = frame.line - 1;
|
|
96
|
+
const column = Math.max(0, (frame.column || 1) - 1);
|
|
97
|
+
let entry = loaded.map.findEntry(line, column);
|
|
98
|
+
// A column before the line's first mapping finds an earlier line: take the line's own.
|
|
99
|
+
if (entry && entry.generatedLine !== line) entry = loaded.map.findEntry(line, Number.MAX_SAFE_INTEGER);
|
|
100
|
+
if (!entry || entry.generatedLine !== line || typeof entry.originalSource !== 'string' || !Number.isInteger(entry.originalLine)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const file = resolveSource(entry.originalSource, loaded, this.classifier.root);
|
|
105
|
+
if (!file) return null;
|
|
106
|
+
const mapped = this.classifier.classify({
|
|
107
|
+
function: frame.function,
|
|
108
|
+
file,
|
|
109
|
+
line: entry.originalLine + 1,
|
|
110
|
+
column: Number.isInteger(entry.originalColumn) ? entry.originalColumn + 1 : null,
|
|
111
|
+
native: false,
|
|
112
|
+
async: frame.async
|
|
113
|
+
});
|
|
114
|
+
// Sources outside the project folder (absolute paths of the build machine) are still the
|
|
115
|
+
// app's own code, unless they come from node_modules.
|
|
116
|
+
if (!mapped.relativeFile && !mapped.module && !file.startsWith('node:')) {
|
|
117
|
+
mapped.inApp = true;
|
|
118
|
+
mapped.relativeFile = looseRelative(entry.originalSource);
|
|
119
|
+
}
|
|
120
|
+
mapped.generated = { file: frame.relativeFile || frame.file, line: frame.line, column: frame.column };
|
|
121
|
+
|
|
122
|
+
const index = loaded.indexOf.get(entry.originalSource);
|
|
123
|
+
const content = index === undefined ? null : loaded.contents[index];
|
|
124
|
+
if (typeof content === 'string') {
|
|
125
|
+
let lines = loaded.lines.get(index);
|
|
126
|
+
if (!lines) {
|
|
127
|
+
lines = content.split(/\r?\n/);
|
|
128
|
+
loaded.lines.set(index, lines);
|
|
129
|
+
}
|
|
130
|
+
this._sourceLines.set(mapped, lines);
|
|
131
|
+
}
|
|
132
|
+
return mapped;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The map of a compiled file, from the cache while the file is unchanged.
|
|
136
|
+
_load(file) {
|
|
137
|
+
let stat;
|
|
138
|
+
try {
|
|
139
|
+
stat = fs.statSync(file);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const cached = this._cache.get(file);
|
|
144
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
145
|
+
this._cache.delete(file);
|
|
146
|
+
this._cache.set(file, cached);
|
|
147
|
+
return cached.loaded;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let loaded = null;
|
|
151
|
+
try {
|
|
152
|
+
loaded = stat.isFile() && this._inProject(file) ? this._read(file, stat.size) : null;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
loaded = null;
|
|
155
|
+
}
|
|
156
|
+
this._cache.delete(file);
|
|
157
|
+
this._cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, loaded });
|
|
158
|
+
while (this._cache.size > this.maxFiles) this._cache.delete(this._cache.keys().next().value);
|
|
159
|
+
return loaded;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_read(file, size) {
|
|
163
|
+
const url = SourceMaps.mappingUrl(readTail(file, size, MAX_TAIL));
|
|
164
|
+
let text = null;
|
|
165
|
+
let dir = path.dirname(file);
|
|
166
|
+
if (url && DATA_URL.test(url)) {
|
|
167
|
+
const [, base64, data] = DATA_URL.exec(url);
|
|
168
|
+
if (data.length > this.maxMapSize * 1.4) return null;
|
|
169
|
+
text = base64 ? Buffer.from(data, 'base64').toString('utf8') : decodeURIComponent(data);
|
|
170
|
+
} else {
|
|
171
|
+
const mapFile = url ? mapPath(url, dir) : `${file}.map`;
|
|
172
|
+
if (!mapFile || !this._inProject(mapFile)) return null;
|
|
173
|
+
text = readSmallFile(mapFile, this.maxMapSize);
|
|
174
|
+
dir = path.dirname(mapFile);
|
|
175
|
+
}
|
|
176
|
+
if (!text) return null;
|
|
177
|
+
|
|
178
|
+
const payload = JSON.parse(text);
|
|
179
|
+
if (!payload || typeof payload !== 'object' || (!Array.isArray(payload.sources) && !Array.isArray(payload.sections))) return null;
|
|
180
|
+
const map = new SourceMap(payload);
|
|
181
|
+
const sources = Array.isArray(payload.sources) ? payload.sources : [];
|
|
182
|
+
const indexOf = new Map();
|
|
183
|
+
sources.forEach((source, index) => {
|
|
184
|
+
if (typeof source === 'string' && !indexOf.has(source)) indexOf.set(source, index);
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
map,
|
|
188
|
+
dir,
|
|
189
|
+
sourceRoot: typeof payload.sourceRoot === 'string' ? payload.sourceRoot : '',
|
|
190
|
+
indexOf,
|
|
191
|
+
contents: Array.isArray(payload.sourcesContent) ? payload.sourcesContent : [],
|
|
192
|
+
lines: new Map()
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The URL in a compiled file's last sourceMappingURL comment.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} text - The end of the file
|
|
200
|
+
* @returns {string|null}
|
|
201
|
+
*/
|
|
202
|
+
static mappingUrl(text) {
|
|
203
|
+
if (!text) return null;
|
|
204
|
+
const at = text.lastIndexOf('sourceMappingURL=');
|
|
205
|
+
if (at === -1) return null;
|
|
206
|
+
const lineStart = text.lastIndexOf('\n', at) + 1;
|
|
207
|
+
let lineEnd = text.indexOf('\n', at);
|
|
208
|
+
if (lineEnd === -1) lineEnd = text.length;
|
|
209
|
+
const match = COMMENT.exec(text.slice(lineStart, lineEnd).replace(/\r$/, '').trim());
|
|
210
|
+
return match ? match[1] : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Inside the project folder and outside node_modules, once symlinks are resolved.
|
|
214
|
+
_inProject(file) {
|
|
215
|
+
let real;
|
|
216
|
+
try {
|
|
217
|
+
real = fs.realpathSync(file);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (this._realRoot === null) {
|
|
222
|
+
try {
|
|
223
|
+
this._realRoot = fs.realpathSync(this.classifier.root);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this._realRoot = this.classifier.root;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const relative = path.relative(this._realRoot, real);
|
|
229
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
|
|
230
|
+
return !relative.split(path.sep).includes('node_modules');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Source lines around a line, as SourceContext reads them from disk.
|
|
236
|
+
*
|
|
237
|
+
* @param {string[]} lines
|
|
238
|
+
* @param {number|null} line - 1-based
|
|
239
|
+
* @returns {{ start: number, lines: string[] }|null}
|
|
240
|
+
*/
|
|
241
|
+
export function contextFrom(lines, line) {
|
|
242
|
+
if (!Number.isInteger(line) || line < 1 || line > lines.length) return null;
|
|
243
|
+
const start = Math.max(1, line - LIMITS.contextLines);
|
|
244
|
+
const end = Math.min(lines.length, line + LIMITS.contextLines);
|
|
245
|
+
return { start, lines: lines.slice(start - 1, end).map((text) => truncate(text, LIMITS.contextLineLength)) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Where a source named in a map is: bundler URLs from the project folder, file URLs and absolute
|
|
249
|
+
// paths as they are, the rest next to the map (after its sourceRoot).
|
|
250
|
+
function resolveSource(source, loaded, root) {
|
|
251
|
+
if (!source) return null;
|
|
252
|
+
if (source.startsWith('file://')) {
|
|
253
|
+
try {
|
|
254
|
+
return fileURLToPath(source);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (source.startsWith('node:')) return source;
|
|
260
|
+
if (BUNDLER_URL.test(source)) return path.resolve(root, source.replace(BUNDLER_URL, ''));
|
|
261
|
+
if (path.isAbsolute(source)) return path.normalize(source);
|
|
262
|
+
const sourceRoot = loaded.sourceRoot;
|
|
263
|
+
if (sourceRoot.startsWith('file://')) {
|
|
264
|
+
try {
|
|
265
|
+
return path.resolve(fileURLToPath(sourceRoot), source);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (BUNDLER_URL.test(sourceRoot)) return path.resolve(root, sourceRoot.replace(BUNDLER_URL, ''), source);
|
|
271
|
+
return path.resolve(loaded.dir, sourceRoot, source);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// A stable name for a source outside the project folder: its path without leading "../" or "/".
|
|
275
|
+
function looseRelative(source) {
|
|
276
|
+
return source
|
|
277
|
+
.replace(BUNDLER_URL, '')
|
|
278
|
+
.replace(/^file:\/\//, '')
|
|
279
|
+
.split(/[\\/]+/)
|
|
280
|
+
.filter((part) => part && part !== '.' && part !== '..')
|
|
281
|
+
.join('/') || null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function mapPath(url, dir) {
|
|
285
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('file:')) return null; // http:, data: of other types
|
|
286
|
+
try {
|
|
287
|
+
return url.startsWith('file:') ? fileURLToPath(url) : path.resolve(dir, decodeURIComponent(url));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readTail(file, size, max) {
|
|
294
|
+
const length = Math.min(size, max);
|
|
295
|
+
const fd = fs.openSync(file, 'r');
|
|
296
|
+
try {
|
|
297
|
+
const buffer = Buffer.alloc(length);
|
|
298
|
+
const read = fs.readSync(fd, buffer, 0, length, size - length);
|
|
299
|
+
return buffer.subarray(0, read).toString('utf8');
|
|
300
|
+
} finally {
|
|
301
|
+
fs.closeSync(fd);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readSmallFile(file, max) {
|
|
306
|
+
try {
|
|
307
|
+
const stat = fs.statSync(file);
|
|
308
|
+
if (!stat.isFile() || stat.size > max) return null;
|
|
309
|
+
return fs.readFileSync(file, 'utf8');
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|