porffor 0.57.16 → 0.57.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/foo.js +1 -26
- package/package.json +1 -1
- package/runtime/index.js +6 -14
- package/runtime/profile.js +248 -0
- package/runtime/flamegraph.js +0 -486
- package/runtime/hotlines.js +0 -71
package/README.md
CHANGED
@@ -40,9 +40,9 @@ Expect nothing to work! Only very limited JS is currently supported. See files i
|
|
40
40
|
|
41
41
|
### Profiling a JS file
|
42
42
|
> [!WARNING]
|
43
|
-
>
|
43
|
+
> Experimental WIP feature!
|
44
44
|
|
45
|
-
**`porf
|
45
|
+
**`porf profile path/to/script.js`**
|
46
46
|
|
47
47
|
### Debugging a JS file
|
48
48
|
> [!WARNING]
|
package/foo.js
CHANGED
@@ -1,26 +1 @@
|
|
1
|
-
|
2
|
-
// Use member expressions rather than destructuring `args` for improved
|
3
|
-
// compatibility with engines that only implement assignment patterns
|
4
|
-
// partially or not at all.
|
5
|
-
const loneCodePoints = args.loneCodePoints;
|
6
|
-
const ranges = args.ranges;
|
7
|
-
const CHUNK_SIZE = 10000;
|
8
|
-
let result = String.fromCodePoint.apply(null, loneCodePoints);
|
9
|
-
for (let i = 0; i < ranges.length; i++) {
|
10
|
-
let range = ranges[i];
|
11
|
-
let start = range[0];
|
12
|
-
let end = range[1];
|
13
|
-
let codePoints = [];
|
14
|
-
for (let length = 0, codePoint = start; codePoint <= end; codePoint++) {
|
15
|
-
codePoints[length++] = codePoint;
|
16
|
-
if (length === CHUNK_SIZE) {
|
17
|
-
result += String.fromCodePoint.apply(null, codePoints);
|
18
|
-
codePoints.length = length = 0;
|
19
|
-
}
|
20
|
-
}
|
21
|
-
result += String.fromCodePoint.apply(null, codePoints);
|
22
|
-
}
|
23
|
-
return result;
|
24
|
-
}
|
25
|
-
|
26
|
-
console.log(buildString({ loneCodePoints: [1337], ranges: [] }));
|
1
|
+
console.log(process.argv);
|
package/package.json
CHANGED
package/runtime/index.js
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
#!/usr/bin/env node
|
2
2
|
import fs from 'node:fs';
|
3
|
-
globalThis.version = '0.57.
|
3
|
+
globalThis.version = '0.57.17';
|
4
4
|
|
5
5
|
// deno compat
|
6
6
|
if (typeof process === 'undefined' && typeof Deno !== 'undefined') {
|
@@ -24,11 +24,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
24
24
|
c: [ 94, 'foo.js foo.c', 'Compile to C source code' ],
|
25
25
|
native: [ 94, 'foo.js foo', 'Compile to a native binary' ],
|
26
26
|
|
27
|
-
'
|
28
|
-
|
29
|
-
hotlines: [ 93, 'foo.js', 'View source with line-by-line performance' ],
|
30
|
-
|
31
|
-
'Debug': [],
|
27
|
+
'Analyze': [],
|
28
|
+
profile: [ 93, 'foo.js', 'View detailed func-by-func performance' ],
|
32
29
|
debug: [ 33, 'foo.js', 'Debug the source of a file' ],
|
33
30
|
dissect: [ 33, 'foo.js', 'Debug the compiled Wasm of a file' ],
|
34
31
|
})) {
|
@@ -93,7 +90,7 @@ const done = async () => {
|
|
93
90
|
};
|
94
91
|
|
95
92
|
let file = process.argv.slice(2).find(x => x[0] !== '-');
|
96
|
-
if (['precompile', 'run', 'wasm', 'native', 'c', '
|
93
|
+
if (['precompile', 'run', 'wasm', 'native', 'c', 'profile', 'debug', 'dissect'].includes(file)) {
|
97
94
|
// remove this arg
|
98
95
|
process.argv.splice(process.argv.indexOf(file), 1);
|
99
96
|
|
@@ -102,13 +99,8 @@ if (['precompile', 'run', 'wasm', 'native', 'c', 'flamegraph', 'hotlines', 'debu
|
|
102
99
|
await done();
|
103
100
|
}
|
104
101
|
|
105
|
-
if (file === '
|
106
|
-
await import('./
|
107
|
-
await done();
|
108
|
-
}
|
109
|
-
|
110
|
-
if (file === 'hotlines') {
|
111
|
-
await import('./hotlines.js');
|
102
|
+
if (file === 'profile') {
|
103
|
+
await import('./profile.js');
|
112
104
|
await done();
|
113
105
|
}
|
114
106
|
|
@@ -0,0 +1,248 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
import { Opcodes, Valtype } from '../compiler/wasmSpec.js';
|
3
|
+
import { number } from '../compiler/encoding.js';
|
4
|
+
import { importedFuncs } from '../compiler/builtins.js';
|
5
|
+
import compile, { createImport } from '../compiler/wrap.js';
|
6
|
+
import fs from 'node:fs';
|
7
|
+
import path from 'node:path';
|
8
|
+
|
9
|
+
const file = process.argv.slice(2).find(x => x[0] !== '-');
|
10
|
+
|
11
|
+
let host = globalThis?.navigator?.userAgent;
|
12
|
+
if (typeof process !== 'undefined' && process.argv0 === 'node') host = 'Node/' + process.versions.node;
|
13
|
+
host ??= 'Unknown';
|
14
|
+
|
15
|
+
const title = process.argv.slice(process.argv.indexOf(file) + 1).find(x => x[0] !== '-') ?? path.basename(file);
|
16
|
+
|
17
|
+
let source = fs.readFileSync(file, 'utf8');
|
18
|
+
|
19
|
+
const samplesFunc = []; // Stores function index for each sample
|
20
|
+
const samplesStart = []; // Stores start timestamp for each sample
|
21
|
+
const samplesEnd = []; // Stores end timestamp for each sample (filled in profile2)
|
22
|
+
let funcLookup = new Map(); // Maps function index to { index, name, internal, ... }
|
23
|
+
let start, end; // Overall start and end time of execution
|
24
|
+
|
25
|
+
// --- Performance Tuning ---
|
26
|
+
const minSampleDurationMs = 0.01; // Ignore samples shorter than this for flame graph hierarchy
|
27
|
+
|
28
|
+
// --- Spinner Globals ---
|
29
|
+
const spinner = ['-', '\\', '|', '/'];
|
30
|
+
let spinIdx = 0;
|
31
|
+
let lastProgressUpdate = 0;
|
32
|
+
const progressUpdateIntervalMs = 500; // Update every 500ms
|
33
|
+
|
34
|
+
// Stack tracking for flamegraph construction
|
35
|
+
let running = new Uint32Array(1024); // Stores indices into samplesFunc/Start/End arrays
|
36
|
+
let runningIdx = 0;
|
37
|
+
|
38
|
+
// --- Profiling Hooks ---
|
39
|
+
createImport('profile1', [ Valtype.i32 ], 0, f => { // pre-call
|
40
|
+
const now = performance.now();
|
41
|
+
samplesStart.push(now);
|
42
|
+
// Store the *index* of the just pushed start time/func id
|
43
|
+
// This index corresponds to the entry in samplesFunc/Start/End
|
44
|
+
const sampleIndex = samplesFunc.push(f) - 1;
|
45
|
+
samplesEnd.push(null); // Placeholder for end time
|
46
|
+
if (runningIdx >= running.length) {
|
47
|
+
// Resize running buffer if needed
|
48
|
+
const newRunning = new Uint32Array(running.length * 2);
|
49
|
+
newRunning.set(running);
|
50
|
+
running = newRunning;
|
51
|
+
}
|
52
|
+
running[runningIdx++] = sampleIndex;
|
53
|
+
});
|
54
|
+
|
55
|
+
createImport('profile2', 0, 0, () => { // post-call
|
56
|
+
const now = performance.now();
|
57
|
+
if (runningIdx > 0) {
|
58
|
+
const sampleIndex = running[--runningIdx];
|
59
|
+
// Only set end time if it hasn't been set (handles potential async overlaps?)
|
60
|
+
if (samplesEnd[sampleIndex] === null) {
|
61
|
+
samplesEnd[sampleIndex] = now;
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
// Check if it's time to update progress spinner
|
66
|
+
if (now - lastProgressUpdate > progressUpdateIntervalMs) {
|
67
|
+
lastProgressUpdate = now;
|
68
|
+
const sampleCount = samplesFunc.length;
|
69
|
+
const currentSpinner = spinner[spinIdx++ % spinner.length];
|
70
|
+
const termWidth = process.stdout.columns || 80;
|
71
|
+
const output = `\r${currentSpinner} Running... Samples: ${sampleCount}`;
|
72
|
+
// Write progress, ensuring the rest of the line is cleared
|
73
|
+
process.stdout.write(output + ' '.repeat(Math.max(0, termWidth - output.length - 1)));
|
74
|
+
}
|
75
|
+
});
|
76
|
+
|
77
|
+
// --- Compilation ---
|
78
|
+
Prefs.treeshakeWasmImports = false; // Keep profile imports
|
79
|
+
globalThis.compileCallback = ({ funcs }) => {
|
80
|
+
funcLookup = new Map(); // Reset map
|
81
|
+
for (const x of funcs) {
|
82
|
+
funcLookup.set(x.index, x);
|
83
|
+
|
84
|
+
// Inject profiling calls around existing calls
|
85
|
+
const w = x.wasm;
|
86
|
+
for (let i = 0; i < w.length; i++) {
|
87
|
+
if (w[i][0] === Opcodes.call) {
|
88
|
+
const f = w[i][1];
|
89
|
+
// Don't profile calls to imported funcs (like profile1/2 itself)
|
90
|
+
if (f < importedFuncs.length) continue;
|
91
|
+
|
92
|
+
// Inject profile2 *after* the call
|
93
|
+
w.splice(i + 1, 0, [ Opcodes.call, importedFuncs.profile2 ]);
|
94
|
+
// Inject function index push and profile1 *before* the call
|
95
|
+
w.splice(i, 0, number(f, Valtype.i32), [ Opcodes.call, importedFuncs.profile1 ]);
|
96
|
+
i += 3; // Skip the 3 instructions we just added
|
97
|
+
}
|
98
|
+
}
|
99
|
+
}
|
100
|
+
};
|
101
|
+
|
102
|
+
console.log('Compiling...');
|
103
|
+
const { exports } = compile(source, undefined, {}, () => {});
|
104
|
+
|
105
|
+
// --- Execution with Progress Spinner ---
|
106
|
+
|
107
|
+
console.log('Starting execution...');
|
108
|
+
// Initial placeholder message
|
109
|
+
const initialMsg = "Running... (waiting for first samples)";
|
110
|
+
const termWidthInitial = process.stdout.columns || 80;
|
111
|
+
process.stdout.write('\r' + initialMsg + ' '.repeat(Math.max(0, termWidthInitial - initialMsg.length -1)));
|
112
|
+
|
113
|
+
start = performance.now();
|
114
|
+
|
115
|
+
try {
|
116
|
+
exports.main();
|
117
|
+
} finally {
|
118
|
+
// Clear the spinner line
|
119
|
+
const termWidthFinal = process.stdout.columns || 80;
|
120
|
+
process.stdout.write('\r' + ' '.repeat(termWidthFinal) + '\r');
|
121
|
+
}
|
122
|
+
|
123
|
+
end = performance.now();
|
124
|
+
console.log(`Execution finished in ${(end - start).toFixed(2)}ms`);
|
125
|
+
|
126
|
+
// --- Data Processing ---
|
127
|
+
|
128
|
+
const totalDuration = end - start;
|
129
|
+
// const processedSamples = []; // Remove: We'll create a filtered list directly
|
130
|
+
const filteredSamples = []; // Samples that meet the duration threshold for the flame graph
|
131
|
+
const funcStats = new Map(); // { index -> { total: 0, count: 0, min: Infinity, max: -Infinity, name: '', internal: false }}
|
132
|
+
|
133
|
+
// Process raw samples: Calculate stats for all, filter for flame graph
|
134
|
+
console.log(`Processing ${samplesFunc.length} raw samples...`);
|
135
|
+
let samplesBelowThreshold = 0;
|
136
|
+
for (let i = 0; i < samplesFunc.length; i++) {
|
137
|
+
const funcIndex = samplesFunc[i];
|
138
|
+
const func = funcLookup.get(funcIndex);
|
139
|
+
const funcName = func ? func.name : `unknown_${funcIndex}`;
|
140
|
+
const isInternal = func ? !!func.internal : false; // Read internal flag
|
141
|
+
const startTime = samplesStart[i];
|
142
|
+
const endTime = samplesEnd[i] === null ? end : samplesEnd[i]; // Cap duration
|
143
|
+
const duration = endTime - startTime;
|
144
|
+
|
145
|
+
if (duration < 0) continue; // Skip potentially erroneous samples
|
146
|
+
|
147
|
+
// --- Update function stats (always do this) ---
|
148
|
+
if (!funcStats.has(funcIndex)) {
|
149
|
+
funcStats.set(funcIndex, { total: 0, count: 0, min: Infinity, max: -Infinity, name: funcName, internal: isInternal }); // Store internal flag
|
150
|
+
}
|
151
|
+
const stats = funcStats.get(funcIndex);
|
152
|
+
stats.total += duration;
|
153
|
+
stats.count++;
|
154
|
+
if (duration < stats.min) stats.min = duration;
|
155
|
+
if (duration > stats.max) stats.max = duration;
|
156
|
+
|
157
|
+
// --- Filter samples for flame graph hierarchy ---
|
158
|
+
if (duration >= minSampleDurationMs) {
|
159
|
+
filteredSamples.push({
|
160
|
+
// Only store data needed for buildHierarchy
|
161
|
+
name: funcName,
|
162
|
+
start: startTime - start, // Relative to overall start
|
163
|
+
end: endTime - start, // Relative to overall start
|
164
|
+
duration: duration,
|
165
|
+
internal: isInternal // Store internal flag for flamegraph nodes
|
166
|
+
});
|
167
|
+
} else {
|
168
|
+
samplesBelowThreshold++;
|
169
|
+
}
|
170
|
+
}
|
171
|
+
console.log(`Filtered out ${samplesBelowThreshold} samples shorter than ${minSampleDurationMs}ms.`);
|
172
|
+
console.log(`Building hierarchy from ${filteredSamples.length} samples...`);
|
173
|
+
|
174
|
+
// --- d3-flame-graph Data Generation ---
|
175
|
+
// Requires a hierarchical structure: { name: 'root', value: total, children: [...] }
|
176
|
+
// where value represents the total time (inclusive of children)
|
177
|
+
function buildHierarchy(samples) {
|
178
|
+
if (!samples || samples.length === 0) {
|
179
|
+
return { name: path.basename(file), value: 0, children: [], internal: false }; // Root is not internal
|
180
|
+
}
|
181
|
+
|
182
|
+
// Sort primarily by start time, secondarily by end time descending (parents first)
|
183
|
+
samples.sort((a, b) => a.start - b.start || b.end - a.end);
|
184
|
+
|
185
|
+
const root = { name: path.basename(file), value: 0, children: [], internal: false }; // Root is not internal
|
186
|
+
root.startTime = 0;
|
187
|
+
root.endTime = totalDuration;
|
188
|
+
const stack = [{ node: root, startTime: root.startTime, endTime: root.endTime }]; // Consistent structure
|
189
|
+
|
190
|
+
for (const sample of samples) {
|
191
|
+
// Pass internal flag from filteredSample to newNode
|
192
|
+
const newNode = { name: sample.name, value: sample.duration, children: [], internal: sample.internal };
|
193
|
+
const sampleStartTime = sample.start;
|
194
|
+
const sampleEndTime = sample.end;
|
195
|
+
|
196
|
+
// Pop stack until parent is found
|
197
|
+
// Parent must start before or at the same time, and end after or at the same time
|
198
|
+
// Accessing .startTime and .endTime on stack elements is correct here
|
199
|
+
while (stack.length > 1 && (stack[stack.length - 1].startTime > sampleStartTime || stack[stack.length - 1].endTime < sampleEndTime)) {
|
200
|
+
stack.pop();
|
201
|
+
}
|
202
|
+
|
203
|
+
const parent = stack[stack.length - 1];
|
204
|
+
parent.node.children.push(newNode); // Correctly access children via parent.node
|
205
|
+
|
206
|
+
// Add node to stack with its *graph node* reference and end time
|
207
|
+
stack.push({ node: newNode, startTime: sampleStartTime, endTime: sampleEndTime });
|
208
|
+
}
|
209
|
+
|
210
|
+
// d3-flamegraph expects `value` to be inclusive time, but we provide durations.
|
211
|
+
// The library handles the aggregation based on children, so we just pass the duration.
|
212
|
+
// Let's ensure root value is the total duration if samples don't cover it
|
213
|
+
// root.value = Math.max(root.value, samples.reduce((sum, s) => sum + s.duration, 0)); // Remove: Not needed with selfValue(true)
|
214
|
+
|
215
|
+
return root;
|
216
|
+
}
|
217
|
+
|
218
|
+
const d3FlameGraphData = buildHierarchy(filteredSamples);
|
219
|
+
|
220
|
+
// --- Bar Chart Data Generation (remains the same) ---
|
221
|
+
const barChartData = Array.from(funcStats.values())
|
222
|
+
.map(stats => ({
|
223
|
+
name: stats.name,
|
224
|
+
total: stats.total,
|
225
|
+
min: stats.min === Infinity ? 0 : stats.min,
|
226
|
+
max: stats.max === -Infinity ? 0 : stats.max,
|
227
|
+
avg: stats.count > 0 ? stats.total / stats.count : 0,
|
228
|
+
count: stats.count,
|
229
|
+
internal: stats.internal // Include internal flag from funcStats
|
230
|
+
}))
|
231
|
+
.sort((a, b) => b.total - a.total) // Sort by total time descending
|
232
|
+
.slice(0, 50); // Limit to top 50 functions
|
233
|
+
|
234
|
+
console.log('uploading...');
|
235
|
+
const { id } = await (await fetch('https://profile.porffor.dev', {
|
236
|
+
method: 'POST',
|
237
|
+
headers: {
|
238
|
+
'Content-Type': 'application/json'
|
239
|
+
},
|
240
|
+
body: JSON.stringify({
|
241
|
+
flame: d3FlameGraphData,
|
242
|
+
chart: barChartData,
|
243
|
+
title,
|
244
|
+
subtitle: `Porffor ${globalThis.version} on ${host.replace('/', ' ')} | ${new Date().toISOString().slice(0, -8).replace('T', ' ')}\n${totalDuration.toFixed(2)}ms | ${samplesFunc.length} samples`
|
245
|
+
})
|
246
|
+
})).json();
|
247
|
+
console.log(`https://profile.porffor.dev/${id}`);
|
248
|
+
process.exit(0);
|
package/runtime/flamegraph.js
DELETED
@@ -1,486 +0,0 @@
|
|
1
|
-
#!/usr/bin/env node
|
2
|
-
import { Opcodes, Valtype } from '../compiler/wasmSpec.js';
|
3
|
-
import { number } from '../compiler/encoding.js';
|
4
|
-
import { importedFuncs } from '../compiler/builtins.js';
|
5
|
-
import compile, { createImport } from '../compiler/wrap.js';
|
6
|
-
import fs from 'node:fs';
|
7
|
-
import http from 'node:http';
|
8
|
-
import path from 'node:path';
|
9
|
-
import url from 'node:url';
|
10
|
-
|
11
|
-
const file = process.argv.slice(2).find(x => x[0] !== '-');
|
12
|
-
if (!file) {
|
13
|
-
console.error('Usage: node flamegraph.js <file.js>');
|
14
|
-
process.exit(1);
|
15
|
-
}
|
16
|
-
let source = fs.readFileSync(file, 'utf8');
|
17
|
-
|
18
|
-
const samplesFunc = []; // Stores function index for each sample
|
19
|
-
const samplesStart = []; // Stores start timestamp for each sample
|
20
|
-
const samplesEnd = []; // Stores end timestamp for each sample (filled in profile2)
|
21
|
-
let funcLookup = new Map(); // Maps function index to { index, name, internal, ... }
|
22
|
-
let start, end; // Overall start and end time of execution
|
23
|
-
|
24
|
-
// --- Performance Tuning ---
|
25
|
-
const minSampleDurationMs = 0.01; // Ignore samples shorter than this for flame graph hierarchy
|
26
|
-
|
27
|
-
// --- Spinner Globals ---
|
28
|
-
const spinner = ['-', '\\', '|', '/'];
|
29
|
-
let spinIdx = 0;
|
30
|
-
let lastProgressUpdate = 0;
|
31
|
-
const progressUpdateIntervalMs = 500; // Update every 500ms
|
32
|
-
|
33
|
-
// Stack tracking for flamegraph construction
|
34
|
-
let running = new Uint32Array(1024); // Stores indices into samplesFunc/Start/End arrays
|
35
|
-
let runningIdx = 0;
|
36
|
-
|
37
|
-
// --- Profiling Hooks ---
|
38
|
-
createImport('profile1', [ Valtype.i32 ], 0, f => { // pre-call
|
39
|
-
const now = performance.now();
|
40
|
-
samplesStart.push(now);
|
41
|
-
// Store the *index* of the just pushed start time/func id
|
42
|
-
// This index corresponds to the entry in samplesFunc/Start/End
|
43
|
-
const sampleIndex = samplesFunc.push(f) - 1;
|
44
|
-
samplesEnd.push(null); // Placeholder for end time
|
45
|
-
if (runningIdx >= running.length) {
|
46
|
-
// Resize running buffer if needed
|
47
|
-
const newRunning = new Uint32Array(running.length * 2);
|
48
|
-
newRunning.set(running);
|
49
|
-
running = newRunning;
|
50
|
-
}
|
51
|
-
running[runningIdx++] = sampleIndex;
|
52
|
-
});
|
53
|
-
|
54
|
-
createImport('profile2', 0, 0, () => { // post-call
|
55
|
-
const now = performance.now();
|
56
|
-
if (runningIdx > 0) {
|
57
|
-
const sampleIndex = running[--runningIdx];
|
58
|
-
// Only set end time if it hasn't been set (handles potential async overlaps?)
|
59
|
-
if (samplesEnd[sampleIndex] === null) {
|
60
|
-
samplesEnd[sampleIndex] = now;
|
61
|
-
}
|
62
|
-
}
|
63
|
-
|
64
|
-
// Check if it's time to update progress spinner
|
65
|
-
if (now - lastProgressUpdate > progressUpdateIntervalMs) {
|
66
|
-
lastProgressUpdate = now;
|
67
|
-
const sampleCount = samplesFunc.length;
|
68
|
-
const currentSpinner = spinner[spinIdx++ % spinner.length];
|
69
|
-
const termWidth = process.stdout.columns || 80;
|
70
|
-
const output = `\r${currentSpinner} Running... Samples: ${sampleCount}`;
|
71
|
-
// Write progress, ensuring the rest of the line is cleared
|
72
|
-
process.stdout.write(output + ' '.repeat(Math.max(0, termWidth - output.length - 1)));
|
73
|
-
}
|
74
|
-
});
|
75
|
-
|
76
|
-
// --- Compilation ---
|
77
|
-
Prefs.treeshakeWasmImports = false; // Keep profile imports
|
78
|
-
globalThis.compileCallback = ({ funcs }) => {
|
79
|
-
funcLookup = new Map(); // Reset map
|
80
|
-
for (const x of funcs) {
|
81
|
-
funcLookup.set(x.index, x);
|
82
|
-
|
83
|
-
// Inject profiling calls around existing calls
|
84
|
-
const w = x.wasm;
|
85
|
-
for (let i = 0; i < w.length; i++) {
|
86
|
-
if (w[i][0] === Opcodes.call) {
|
87
|
-
const f = w[i][1];
|
88
|
-
// Don't profile calls to imported funcs (like profile1/2 itself)
|
89
|
-
if (f < importedFuncs.length) continue;
|
90
|
-
|
91
|
-
// Inject profile2 *after* the call
|
92
|
-
w.splice(i + 1, 0, [ Opcodes.call, importedFuncs.profile2 ]);
|
93
|
-
// Inject function index push and profile1 *before* the call
|
94
|
-
w.splice(i, 0, number(f, Valtype.i32), [ Opcodes.call, importedFuncs.profile1 ]);
|
95
|
-
i += 3; // Skip the 3 instructions we just added
|
96
|
-
}
|
97
|
-
}
|
98
|
-
}
|
99
|
-
};
|
100
|
-
|
101
|
-
console.log('Compiling...');
|
102
|
-
const { exports } = compile(source, undefined, {}, () => {});
|
103
|
-
|
104
|
-
// --- Execution with Progress Spinner ---
|
105
|
-
|
106
|
-
console.log('Starting execution...');
|
107
|
-
// Initial placeholder message
|
108
|
-
const initialMsg = "Running... (waiting for first samples)";
|
109
|
-
const termWidthInitial = process.stdout.columns || 80;
|
110
|
-
process.stdout.write('\r' + initialMsg + ' '.repeat(Math.max(0, termWidthInitial - initialMsg.length -1)));
|
111
|
-
|
112
|
-
start = performance.now();
|
113
|
-
|
114
|
-
try {
|
115
|
-
exports.main();
|
116
|
-
} finally {
|
117
|
-
// Clear the spinner line
|
118
|
-
const termWidthFinal = process.stdout.columns || 80;
|
119
|
-
process.stdout.write('\r' + ' '.repeat(termWidthFinal) + '\r');
|
120
|
-
}
|
121
|
-
|
122
|
-
end = performance.now();
|
123
|
-
console.log(`Execution finished in ${(end - start).toFixed(2)}ms`);
|
124
|
-
|
125
|
-
// --- Data Processing ---
|
126
|
-
|
127
|
-
const totalDuration = end - start;
|
128
|
-
// const processedSamples = []; // Remove: We'll create a filtered list directly
|
129
|
-
const filteredSamples = []; // Samples that meet the duration threshold for the flame graph
|
130
|
-
const funcStats = new Map(); // { index -> { total: 0, count: 0, min: Infinity, max: -Infinity, name: '', internal: false }}
|
131
|
-
|
132
|
-
// Process raw samples: Calculate stats for all, filter for flame graph
|
133
|
-
console.log(`Processing ${samplesFunc.length} raw samples...`);
|
134
|
-
let samplesBelowThreshold = 0;
|
135
|
-
for (let i = 0; i < samplesFunc.length; i++) {
|
136
|
-
const funcIndex = samplesFunc[i];
|
137
|
-
const func = funcLookup.get(funcIndex);
|
138
|
-
const funcName = func ? func.name : `unknown_${funcIndex}`;
|
139
|
-
const isInternal = func ? !!func.internal : false; // Read internal flag
|
140
|
-
const startTime = samplesStart[i];
|
141
|
-
const endTime = samplesEnd[i] === null ? end : samplesEnd[i]; // Cap duration
|
142
|
-
const duration = endTime - startTime;
|
143
|
-
|
144
|
-
if (duration < 0) continue; // Skip potentially erroneous samples
|
145
|
-
|
146
|
-
// --- Update function stats (always do this) ---
|
147
|
-
if (!funcStats.has(funcIndex)) {
|
148
|
-
funcStats.set(funcIndex, { total: 0, count: 0, min: Infinity, max: -Infinity, name: funcName, internal: isInternal }); // Store internal flag
|
149
|
-
}
|
150
|
-
const stats = funcStats.get(funcIndex);
|
151
|
-
stats.total += duration;
|
152
|
-
stats.count++;
|
153
|
-
if (duration < stats.min) stats.min = duration;
|
154
|
-
if (duration > stats.max) stats.max = duration;
|
155
|
-
|
156
|
-
// --- Filter samples for flame graph hierarchy ---
|
157
|
-
if (duration >= minSampleDurationMs) {
|
158
|
-
filteredSamples.push({
|
159
|
-
// Only store data needed for buildHierarchy
|
160
|
-
name: funcName,
|
161
|
-
start: startTime - start, // Relative to overall start
|
162
|
-
end: endTime - start, // Relative to overall start
|
163
|
-
duration: duration,
|
164
|
-
internal: isInternal // Store internal flag for flamegraph nodes
|
165
|
-
});
|
166
|
-
} else {
|
167
|
-
samplesBelowThreshold++;
|
168
|
-
}
|
169
|
-
}
|
170
|
-
console.log(`Filtered out ${samplesBelowThreshold} samples shorter than ${minSampleDurationMs}ms.`);
|
171
|
-
console.log(`Building hierarchy from ${filteredSamples.length} samples...`);
|
172
|
-
|
173
|
-
// --- d3-flame-graph Data Generation ---
|
174
|
-
// Requires a hierarchical structure: { name: 'root', value: total, children: [...] }
|
175
|
-
// where value represents the total time (inclusive of children)
|
176
|
-
function buildHierarchy(samples) {
|
177
|
-
if (!samples || samples.length === 0) {
|
178
|
-
return { name: path.basename(file), value: 0, children: [], internal: false }; // Root is not internal
|
179
|
-
}
|
180
|
-
|
181
|
-
// Sort primarily by start time, secondarily by end time descending (parents first)
|
182
|
-
samples.sort((a, b) => a.start - b.start || b.end - a.end);
|
183
|
-
|
184
|
-
const root = { name: path.basename(file), value: 0, children: [], internal: false }; // Root is not internal
|
185
|
-
root.startTime = 0;
|
186
|
-
root.endTime = totalDuration;
|
187
|
-
const stack = [{ node: root, startTime: root.startTime, endTime: root.endTime }]; // Consistent structure
|
188
|
-
|
189
|
-
for (const sample of samples) {
|
190
|
-
// Pass internal flag from filteredSample to newNode
|
191
|
-
const newNode = { name: sample.name, value: sample.duration, children: [], internal: sample.internal };
|
192
|
-
const sampleStartTime = sample.start;
|
193
|
-
const sampleEndTime = sample.end;
|
194
|
-
|
195
|
-
// Pop stack until parent is found
|
196
|
-
// Parent must start before or at the same time, and end after or at the same time
|
197
|
-
// Accessing .startTime and .endTime on stack elements is correct here
|
198
|
-
while (stack.length > 1 && (stack[stack.length - 1].startTime > sampleStartTime || stack[stack.length - 1].endTime < sampleEndTime)) {
|
199
|
-
stack.pop();
|
200
|
-
}
|
201
|
-
|
202
|
-
const parent = stack[stack.length - 1];
|
203
|
-
parent.node.children.push(newNode); // Correctly access children via parent.node
|
204
|
-
|
205
|
-
// Add node to stack with its *graph node* reference and end time
|
206
|
-
stack.push({ node: newNode, startTime: sampleStartTime, endTime: sampleEndTime });
|
207
|
-
}
|
208
|
-
|
209
|
-
// d3-flamegraph expects `value` to be inclusive time, but we provide durations.
|
210
|
-
// The library handles the aggregation based on children, so we just pass the duration.
|
211
|
-
// Let's ensure root value is the total duration if samples don't cover it
|
212
|
-
// root.value = Math.max(root.value, samples.reduce((sum, s) => sum + s.duration, 0)); // Remove: Not needed with selfValue(true)
|
213
|
-
|
214
|
-
return root;
|
215
|
-
}
|
216
|
-
|
217
|
-
const d3FlameGraphData = buildHierarchy(filteredSamples);
|
218
|
-
|
219
|
-
// --- Bar Chart Data Generation (remains the same) ---
|
220
|
-
const barChartData = Array.from(funcStats.values())
|
221
|
-
.map(stats => ({
|
222
|
-
name: stats.name,
|
223
|
-
total: stats.total,
|
224
|
-
min: stats.min === Infinity ? 0 : stats.min,
|
225
|
-
max: stats.max === -Infinity ? 0 : stats.max,
|
226
|
-
avg: stats.count > 0 ? stats.total / stats.count : 0,
|
227
|
-
count: stats.count,
|
228
|
-
internal: stats.internal // Include internal flag from funcStats
|
229
|
-
}))
|
230
|
-
.sort((a, b) => b.total - a.total) // Sort by total time descending
|
231
|
-
.slice(0, 50); // Limit to top 50 functions
|
232
|
-
|
233
|
-
// --- HTML Generation ---
|
234
|
-
function generateHtml(flameData, chartData) {
|
235
|
-
const flameJson = JSON.stringify(flameData);
|
236
|
-
const chartJson = JSON.stringify(chartData);
|
237
|
-
|
238
|
-
return `
|
239
|
-
<!DOCTYPE html>
|
240
|
-
<html>
|
241
|
-
<head>
|
242
|
-
<meta charset="utf-8">
|
243
|
-
<title>Porffor Profile: ${path.basename(file)}</title>
|
244
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/dist/d3-flamegraph.css">
|
245
|
-
<style>
|
246
|
-
@import url(https://fonts.bunny.net/css?family=jetbrains-mono:400,600,800);
|
247
|
-
|
248
|
-
:root {
|
249
|
-
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
250
|
-
--header-primary: #ffffff;
|
251
|
-
--header-secondary: #b9bbbe;
|
252
|
-
--text-normal: #ffffff;
|
253
|
-
--text-muted: #d0d4d8;
|
254
|
-
--accent-dark: #3e2066;
|
255
|
-
--accent: #8545cf;
|
256
|
-
--accent-light: #9c60e0;
|
257
|
-
--background-primary: #100420;
|
258
|
-
--background-secondary: #200840;
|
259
|
-
}
|
260
|
-
html, body {
|
261
|
-
margin: 0;
|
262
|
-
padding: 0;
|
263
|
-
}
|
264
|
-
body {
|
265
|
-
font-family: var(--font-family);
|
266
|
-
margin: 16px;
|
267
|
-
background-color: var(--background-primary);
|
268
|
-
color: var(--text-muted);
|
269
|
-
}
|
270
|
-
h2 {
|
271
|
-
text-align: center;
|
272
|
-
color: var(--header-primary);
|
273
|
-
font-weight: 800;
|
274
|
-
margin: 0;
|
275
|
-
margin-bottom: 16px;
|
276
|
-
font-size: 24px;
|
277
|
-
}
|
278
|
-
#flamegraph-container, #barchart-container {
|
279
|
-
border: 2px solid var(--accent);
|
280
|
-
margin-bottom: 32px;
|
281
|
-
background-color: var(--background-secondary);
|
282
|
-
padding: 16px;
|
283
|
-
border-radius: 0;
|
284
|
-
}
|
285
|
-
#flamegraph-details {
|
286
|
-
margin-top: 10px;
|
287
|
-
font-size: 14px;
|
288
|
-
color: var(--text-muted);
|
289
|
-
min-height: 2em;
|
290
|
-
}
|
291
|
-
.chart-title {
|
292
|
-
font-size: 20px;
|
293
|
-
font-weight: 600;
|
294
|
-
margin-bottom: 15px;
|
295
|
-
color: var(--header-primary);
|
296
|
-
border-bottom: 1px solid var(--accent);
|
297
|
-
padding-bottom: 8px;
|
298
|
-
margin-top: 0;
|
299
|
-
}
|
300
|
-
.bar-row { display: flex; align-items: center; font-size: 14px; white-space: nowrap; height: 24px; }
|
301
|
-
.bar-label { width: 320px; overflow: hidden; text-overflow: ellipsis; padding-right: 10px; color: var(--text-muted); font-weight: 600;}
|
302
|
-
.bar-rect-bg { flex-grow: 1; background-color: var(--background-secondary); border: 1px solid var(--accent-dark); height: 100%; position: relative; min-width: 100px; border-radius: 0; }
|
303
|
-
.bar-rect { background-color: var(--accent); height: 100%; position: absolute; left: 0; top: 0; border-radius: 0; display: flex; align-items: center; }
|
304
|
-
.bar-value { color: var(--header-primary); font-size: 14px; position: relative; left: 6px; }
|
305
|
-
.bar-stats-inline { position: absolute; right: 8px; top: 0; height: 100%; display: flex; align-items: center; font-size: 12px; color: var(--header-secondary); }
|
306
|
-
#flamegraph-chart {
|
307
|
-
width: 100%;
|
308
|
-
background-color: var(--background-secondary);
|
309
|
-
min-height: 400px;
|
310
|
-
}
|
311
|
-
.d3-flame-graph rect {
|
312
|
-
stroke: var(--background-primary);
|
313
|
-
stroke-width: 0.5;
|
314
|
-
fill-opacity: .9;
|
315
|
-
}
|
316
|
-
.d3-flame-graph rect:hover {
|
317
|
-
stroke: var(--accent-light);
|
318
|
-
stroke-width: 1;
|
319
|
-
fill-opacity: 1;
|
320
|
-
}
|
321
|
-
.d3-flame-graph-label {
|
322
|
-
user-select: none;
|
323
|
-
/* white-space: unset; */
|
324
|
-
/* text-overflow: unset; */
|
325
|
-
overflow: hidden;
|
326
|
-
font-size: 12px;
|
327
|
-
font-family: inherit;
|
328
|
-
/* padding: 0 0 0; */
|
329
|
-
color: black;
|
330
|
-
/* overflow-wrap: break-word; */
|
331
|
-
line-height: 1.5;
|
332
|
-
}
|
333
|
-
.d3-flame-graph .depth-0 .d3-flame-graph-label {
|
334
|
-
font-size: 16px;
|
335
|
-
}
|
336
|
-
</style>
|
337
|
-
</head>
|
338
|
-
<body>
|
339
|
-
<h2>Porffor Profile: ${path.basename(file)} (${totalDuration.toFixed(2)}ms total)</h2>
|
340
|
-
|
341
|
-
<div id="flamegraph-container" style="position: relative;">
|
342
|
-
<div id="flamegraph-chart"></div>
|
343
|
-
<div id="flamegraph-details"></div>
|
344
|
-
</div>
|
345
|
-
|
346
|
-
<div id="barchart-container">
|
347
|
-
<div class="chart-title">Function Execution Time</div>
|
348
|
-
<div id="barchart"></div>
|
349
|
-
</div>
|
350
|
-
|
351
|
-
<script src="https://d3js.org/d3.v7.min.js"></script>
|
352
|
-
<script src="https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/dist/d3-flamegraph.min.js"></script>
|
353
|
-
<script>
|
354
|
-
const flameData = ${flameJson};
|
355
|
-
const chartData = ${chartJson};
|
356
|
-
const totalValue = flameData.value;
|
357
|
-
|
358
|
-
const flamegraphContainer = document.getElementById('flamegraph-chart');
|
359
|
-
const detailsElement = document.getElementById('flamegraph-details');
|
360
|
-
const graphWidth = flamegraphContainer.clientWidth || 960;
|
361
|
-
const graphHeight = flamegraphContainer.clientHeight || 400;
|
362
|
-
|
363
|
-
const chartDataByName = chartData.reduce((acc, d) => {
|
364
|
-
acc[d.name] = d;
|
365
|
-
return acc;
|
366
|
-
}, {});
|
367
|
-
|
368
|
-
var flameChart = flamegraph()
|
369
|
-
.width(graphWidth)
|
370
|
-
.height(graphHeight)
|
371
|
-
.cellHeight(18)
|
372
|
-
.transitionDuration(0)
|
373
|
-
.minFrameSize(1)
|
374
|
-
.sort(true)
|
375
|
-
.selfValue(true)
|
376
|
-
.setDetailsElement(detailsElement)
|
377
|
-
.setColorHue('warm')
|
378
|
-
// Add a color mapper to dim internal functions
|
379
|
-
.setColorMapper(function(d, originalColor) {
|
380
|
-
if (d.data.internal) {
|
381
|
-
return 'var(--accent-light)';
|
382
|
-
}
|
383
|
-
return originalColor; // Return original color if not internal or parsing failed
|
384
|
-
})
|
385
|
-
.label(function(d) {
|
386
|
-
if (d.data.name === 'root') return '';
|
387
|
-
|
388
|
-
return \`\${d.data.name}: \${d.value.toFixed(2)}ms (\${(d.value / (chartDataByName[d.data.name]?.avg ?? d.value)).toFixed(2)}x avg)\`;
|
389
|
-
});
|
390
|
-
|
391
|
-
if (flameData && flameData.children && flameData.children.length > 0) {
|
392
|
-
d3.select("#flamegraph-chart")
|
393
|
-
.datum(flameData)
|
394
|
-
.call(flameChart);
|
395
|
-
} else {
|
396
|
-
flamegraphContainer.textContent = 'No profiling data captured for flame graph.';
|
397
|
-
}
|
398
|
-
|
399
|
-
window.addEventListener('resize', () => {
|
400
|
-
d3.select("#flamegraph-chart").selectAll(":scope > *").remove();
|
401
|
-
|
402
|
-
// Update width and height on resize
|
403
|
-
const newWidth = flamegraphContainer.clientWidth || 960;
|
404
|
-
const newHeight = flamegraphContainer.clientHeight || 400;
|
405
|
-
flameChart.width(newWidth).height(newHeight);
|
406
|
-
|
407
|
-
d3.select("#flamegraph-chart")
|
408
|
-
.datum(flameData)
|
409
|
-
.call(flameChart);
|
410
|
-
});
|
411
|
-
|
412
|
-
const barChartContainer = document.getElementById('barchart');
|
413
|
-
const maxTotalTime = chartData.length > 0 ? Math.max(...chartData.map(d => d.total)) : 1;
|
414
|
-
|
415
|
-
chartData.forEach(d => {
|
416
|
-
const row = document.createElement('div');
|
417
|
-
row.className = 'bar-row';
|
418
|
-
const label = document.createElement('div');
|
419
|
-
label.className = 'bar-label';
|
420
|
-
label.textContent = d.name;
|
421
|
-
label.title = d.name;
|
422
|
-
// Add style if internal flag (propagated via barChartData) is true
|
423
|
-
if (d.internal) {
|
424
|
-
label.style.color = 'var(--accent-light)';
|
425
|
-
}
|
426
|
-
|
427
|
-
const barBg = document.createElement('div');
|
428
|
-
barBg.className = 'bar-rect-bg';
|
429
|
-
const bar = document.createElement('div');
|
430
|
-
bar.className = 'bar-rect';
|
431
|
-
const barWidthPercent = (d.total / maxTotalTime) * 100;
|
432
|
-
bar.style.width = barWidthPercent.toFixed(1) + '%';
|
433
|
-
const value = document.createElement('span');
|
434
|
-
value.className = 'bar-value';
|
435
|
-
value.textContent = d.total.toFixed(2) + 'ms';
|
436
|
-
bar.appendChild(value);
|
437
|
-
const statsText = \`avg: \${d.avg.toFixed(2)}ms | min: \${d.min.toFixed(2)}ms | max: \${d.max.toFixed(2)}ms | count: \${d.count}\`;
|
438
|
-
const statsInline = document.createElement('div');
|
439
|
-
statsInline.className = 'bar-stats-inline';
|
440
|
-
statsInline.textContent = statsText;
|
441
|
-
barBg.appendChild(bar);
|
442
|
-
barBg.appendChild(statsInline);
|
443
|
-
row.appendChild(label);
|
444
|
-
row.appendChild(barBg);
|
445
|
-
barChartContainer.appendChild(row);
|
446
|
-
});
|
447
|
-
|
448
|
-
if (chartData.length === 0) {
|
449
|
-
barChartContainer.textContent = 'No profiling data captured for bar chart.';
|
450
|
-
}
|
451
|
-
</script>
|
452
|
-
</body>
|
453
|
-
</html>
|
454
|
-
`;
|
455
|
-
}
|
456
|
-
|
457
|
-
// --- HTTP Server (remains the same) ---
|
458
|
-
const html = generateHtml(d3FlameGraphData, barChartData);
|
459
|
-
|
460
|
-
const port = 8080;
|
461
|
-
const server = http.createServer((req, res) => {
|
462
|
-
if (req.url === '/' || req.url === '/index.html') {
|
463
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
464
|
-
res.end(html);
|
465
|
-
} else if (req.url === '/favicon.ico') {
|
466
|
-
res.writeHead(204, { 'Content-Type': 'image/x-icon' }); // No content for favicon
|
467
|
-
res.end();
|
468
|
-
} else {
|
469
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
470
|
-
res.end('Not Found');
|
471
|
-
}
|
472
|
-
});
|
473
|
-
|
474
|
-
server.listen(port, () => {
|
475
|
-
console.log(`\nProfile report available at: http://localhost:${port}`);
|
476
|
-
console.log('Press Ctrl+C to stop the server.');
|
477
|
-
});
|
478
|
-
|
479
|
-
server.on('error', (err) => {
|
480
|
-
if (err.code === 'EADDRINUSE') {
|
481
|
-
console.error(`Error: Port ${port} is already in use. Please stop the other process or choose a different port.`);
|
482
|
-
} else {
|
483
|
-
console.error('Server error:', err);
|
484
|
-
}
|
485
|
-
process.exit(1);
|
486
|
-
});
|
package/runtime/hotlines.js
DELETED
@@ -1,71 +0,0 @@
|
|
1
|
-
#!/usr/bin/env node
|
2
|
-
import compile, { createImport } from '../compiler/wrap.js';
|
3
|
-
import fs from 'node:fs';
|
4
|
-
|
5
|
-
const file = process.argv.slice(2).find(x => x[0] !== '-');
|
6
|
-
let source = fs.readFileSync(file, 'utf8');
|
7
|
-
|
8
|
-
let profileId = 0;
|
9
|
-
source = source.replace(/^[^\n}]*;$/mg, _ => `profile1(Porffor.wasm.i32.const(${profileId}));${_}profile2(Porffor.wasm.i32.const(${profileId++}));`)
|
10
|
-
|
11
|
-
let tmp = new Array(profileId).fill(0);
|
12
|
-
let times = new Array(profileId).fill(0);
|
13
|
-
let samples = 0;
|
14
|
-
|
15
|
-
const percents = process.argv.includes('-%');
|
16
|
-
|
17
|
-
const spinner = ['-', '\\', '|', '/'];
|
18
|
-
let spin = 0;
|
19
|
-
let last = 0;
|
20
|
-
|
21
|
-
createImport('profile1', [ Valtype.i32 ], 0, n => {
|
22
|
-
tmp[n] = performance.now();
|
23
|
-
});
|
24
|
-
createImport('profile2', [ Valtype.i32 ], 0, n => {
|
25
|
-
const t = performance.now();
|
26
|
-
times[n] += t - tmp[n];
|
27
|
-
|
28
|
-
samples++;
|
29
|
-
if (t > last) {
|
30
|
-
process.stdout.write(`\r${spinner[spin++ % 4]} running: collected ${samples} samples...`);
|
31
|
-
last = t + 100;
|
32
|
-
}
|
33
|
-
});
|
34
|
-
|
35
|
-
try {
|
36
|
-
const { exports } = compile(source, undefined);
|
37
|
-
const start = performance.now();
|
38
|
-
exports.main();
|
39
|
-
|
40
|
-
const total = performance.now() - start;
|
41
|
-
console.log(`\ntotal: ${total}ms\nsamples: ${samples}\n\n\n` + source.split('\n').map(x => {
|
42
|
-
let time = 0;
|
43
|
-
if (x.startsWith('profile')) {
|
44
|
-
const id = parseInt(x.slice(32, x.indexOf(')')));
|
45
|
-
time = times[id]
|
46
|
-
}
|
47
|
-
|
48
|
-
let color = [ 0, 0, 0 ];
|
49
|
-
if (time) {
|
50
|
-
let relativeTime = time / total;
|
51
|
-
if (percents) time = relativeTime;
|
52
|
-
|
53
|
-
relativeTime = Math.sqrt(relativeTime);
|
54
|
-
color = [ (relativeTime * 250) | 0, (Math.sin(relativeTime * Math.PI) * 50) | 0, 0 ];
|
55
|
-
}
|
56
|
-
|
57
|
-
const ansiColor = `2;${color[0]};${color[1]};${color[2]}m`;
|
58
|
-
|
59
|
-
const line = x.replace(/profile[0-9]\(Porffor.wasm.i32.const\([0-9]+\)\);/g, '');
|
60
|
-
|
61
|
-
if (percents) return `\x1b[48;${ansiColor}\x1b[97m${time ? ((time * 100).toFixed(0).padStart(4, ' ') + '%') : ' '}\x1b[0m\x1b[38;${ansiColor}▌\x1b[0m ${line}`;
|
62
|
-
|
63
|
-
let digits = 2;
|
64
|
-
if (time >= 100) digits = 1;
|
65
|
-
if (time >= 1000) digits = 0;
|
66
|
-
|
67
|
-
return `\x1b[48;${ansiColor}\x1b[97m${time ? time.toFixed(digits).padStart(6, ' ') : ' '}\x1b[0m\x1b[38;${ansiColor}▌\x1b[0m ${line}`;
|
68
|
-
}).join('\n'));
|
69
|
-
} catch (e) {
|
70
|
-
console.error(e);
|
71
|
-
}
|