opencons 0.1.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 +382 -0
- package/opencons.d.ts +55 -0
- package/package.json +73 -0
- package/scripts/vendor-d3.js +22 -0
- package/src/core/context.js +44 -0
- package/src/core/index.js +198 -0
- package/src/core/tracer.js +252 -0
- package/src/drivers/db-language.js +207 -0
- package/src/drivers/detect.js +62 -0
- package/src/drivers/drizzle.js +87 -0
- package/src/drivers/index.js +43 -0
- package/src/drivers/mongoose.js +89 -0
- package/src/drivers/mysql2.js +116 -0
- package/src/drivers/pg.js +130 -0
- package/src/drivers/prisma.js +109 -0
- package/src/drivers/record.js +158 -0
- package/src/index.js +28 -0
- package/src/integrations/nest-lifecycle.js +357 -0
- package/src/integrations/nest.js +89 -0
- package/src/interceptors/express.js +270 -0
- package/src/interceptors/require-hook.js +109 -0
- package/src/lib/config.js +139 -0
- package/src/lib/errors.js +54 -0
- package/src/lib/http-response.js +37 -0
- package/src/lib/logger.js +69 -0
- package/src/lib/serialize.js +22 -0
- package/src/server/static.js +165 -0
- package/src/server/ws.js +62 -0
- package/src/store/source-cache.js +120 -0
- package/src/store/trace-store.js +117 -0
- package/src/transform/ast.js +255 -0
- package/src/transform/natural-language.js +146 -0
- package/src/transform/probe.js +161 -0
- package/src/transform/register.js +44 -0
- package/src/utils/label.js +26 -0
- package/src/utils/observable.js +103 -0
- package/widget/app.js +356 -0
- package/widget/db-language.js +90 -0
- package/widget/graph.js +1167 -0
- package/widget/index.html +132 -0
- package/widget/styles.css +773 -0
- package/widget/timeline.js +57 -0
- package/widget/vendor/d3.min.js +2 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const sourceCache = require('../store/source-cache');
|
|
7
|
+
const { logger } = require('../lib/logger');
|
|
8
|
+
const { WidgetServerError } = require('../lib/errors');
|
|
9
|
+
const { sendText, sendJsonError, sendJson } = require('../lib/http-response');
|
|
10
|
+
|
|
11
|
+
/** @type {http.Server | null} */
|
|
12
|
+
let server = null;
|
|
13
|
+
|
|
14
|
+
/** @type {number | null} */
|
|
15
|
+
let listeningPort = null;
|
|
16
|
+
|
|
17
|
+
const WIDGET_ROOT = path.join(__dirname, '..', '..', 'widget');
|
|
18
|
+
|
|
19
|
+
const MIME_TYPES = {
|
|
20
|
+
'.html': 'text/html; charset=utf-8',
|
|
21
|
+
'.css': 'text/css; charset=utf-8',
|
|
22
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
23
|
+
'.json': 'application/json; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.ico': 'image/x-icon',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {import('http').IncomingMessage} req
|
|
31
|
+
* @param {import('http').ServerResponse} res
|
|
32
|
+
*/
|
|
33
|
+
function handleWidgetRequest(req, res) {
|
|
34
|
+
try {
|
|
35
|
+
if (req.url && req.url.startsWith('/api/source')) {
|
|
36
|
+
return handleSourceApi(req, res);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
|
|
40
|
+
const filePath = path.normalize(path.join(WIDGET_ROOT, urlPath));
|
|
41
|
+
|
|
42
|
+
if (!filePath.startsWith(WIDGET_ROOT)) {
|
|
43
|
+
sendText(res, 403, 'Forbidden');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fs.readFile(filePath, (err, data) => {
|
|
48
|
+
if (err) {
|
|
49
|
+
sendText(res, 404, 'Not found');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ext = path.extname(filePath);
|
|
54
|
+
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
|
55
|
+
res.end(data);
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger.error('Widget request handler failed', err);
|
|
59
|
+
sendJsonError(res, 500, { error: 'Internal server error', code: 'WIDGET_REQUEST_ERROR' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {number} port
|
|
65
|
+
* @param {number} [maxAttempts]
|
|
66
|
+
* @returns {Promise<{ server: http.Server, port: number }>}
|
|
67
|
+
*/
|
|
68
|
+
function createStaticServer(port, maxAttempts = 10) {
|
|
69
|
+
if (server && listeningPort) {
|
|
70
|
+
return Promise.resolve({ server, port: listeningPort });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
let attempt = 0;
|
|
75
|
+
|
|
76
|
+
const tryListen = (candidatePort) => {
|
|
77
|
+
const httpServer = http.createServer(handleWidgetRequest);
|
|
78
|
+
|
|
79
|
+
const onError = (err) => {
|
|
80
|
+
httpServer.removeListener('error', onError);
|
|
81
|
+
httpServer.close(() => {});
|
|
82
|
+
|
|
83
|
+
if (err.code === 'EADDRINUSE' && attempt < maxAttempts - 1) {
|
|
84
|
+
attempt += 1;
|
|
85
|
+
const nextPort = port + attempt;
|
|
86
|
+
|
|
87
|
+
if (attempt === 1) {
|
|
88
|
+
logger.warn(
|
|
89
|
+
`Port ${candidatePort} is in use — trying ${nextPort}. ` +
|
|
90
|
+
'Kill the old process or set opencons({ port: N }).'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
tryListen(nextPort);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reject(new WidgetServerError(`Failed to bind widget server on port ${candidatePort}`, err));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
httpServer.once('error', onError);
|
|
102
|
+
httpServer.listen(candidatePort, () => {
|
|
103
|
+
httpServer.removeListener('error', onError);
|
|
104
|
+
server = httpServer;
|
|
105
|
+
listeningPort = candidatePort;
|
|
106
|
+
|
|
107
|
+
logger.info(`Widget → http://localhost:${candidatePort}`);
|
|
108
|
+
|
|
109
|
+
if (candidatePort !== port) {
|
|
110
|
+
logger.warn(
|
|
111
|
+
`Port ${port} was busy. Open http://localhost:${candidatePort} (not ${port}).`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
resolve({ server: httpServer, port: candidatePort });
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
tryListen(port);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @returns {http.Server | null}
|
|
125
|
+
*/
|
|
126
|
+
function getServer() {
|
|
127
|
+
return server;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @returns {number | null}
|
|
132
|
+
*/
|
|
133
|
+
function getListeningPort() {
|
|
134
|
+
return listeningPort;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {import('http').IncomingMessage} req
|
|
139
|
+
* @param {import('http').ServerResponse} res
|
|
140
|
+
*/
|
|
141
|
+
function handleSourceApi(req, res) {
|
|
142
|
+
const url = new URL(req.url, 'http://localhost');
|
|
143
|
+
const file = url.searchParams.get('file');
|
|
144
|
+
const line = Number(url.searchParams.get('line')) || 1;
|
|
145
|
+
|
|
146
|
+
if (!file) {
|
|
147
|
+
sendJsonError(res, 400, { error: 'file query param required', code: 'MISSING_FILE_PARAM' });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const snippet = sourceCache.getSnippet(file, line);
|
|
152
|
+
|
|
153
|
+
if (!snippet) {
|
|
154
|
+
sendJsonError(res, 404, { error: 'source not found', code: 'SOURCE_NOT_FOUND' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sendJson(res, snippet);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
createStaticServer,
|
|
163
|
+
getServer,
|
|
164
|
+
getListeningPort,
|
|
165
|
+
};
|
package/src/server/ws.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { WebSocketServer } = require('ws');
|
|
4
|
+
const { getServer, getListeningPort } = require('./static');
|
|
5
|
+
const { logger } = require('../lib/logger');
|
|
6
|
+
const { WebSocketError } = require('../lib/errors');
|
|
7
|
+
|
|
8
|
+
/** @type {import('ws').WebSocketServer | null} */
|
|
9
|
+
let wss = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {ReturnType<import('../store/trace-store').createTraceStore>} traceStore
|
|
13
|
+
*/
|
|
14
|
+
function createWebSocketServer(traceStore) {
|
|
15
|
+
if (wss) return wss;
|
|
16
|
+
|
|
17
|
+
const httpServer = getServer();
|
|
18
|
+
const port = getListeningPort();
|
|
19
|
+
|
|
20
|
+
if (!httpServer || !port) {
|
|
21
|
+
throw new WebSocketError('HTTP server must be listening before WebSocket server');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
wss = new WebSocketServer({ server: httpServer });
|
|
25
|
+
|
|
26
|
+
wss.on('connection', (socket) => {
|
|
27
|
+
traceStore.subscribe(socket);
|
|
28
|
+
|
|
29
|
+
socket.on('message', (raw) => {
|
|
30
|
+
try {
|
|
31
|
+
const message = JSON.parse(raw.toString());
|
|
32
|
+
|
|
33
|
+
if (message.type === 'get_history') {
|
|
34
|
+
const history = traceStore.getAll(message.limit || 50);
|
|
35
|
+
socket.send(JSON.stringify({ type: 'history', payload: history }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
logger.debug(`Ignoring unknown WebSocket message type: ${message.type}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
logger.debug('Ignoring malformed WebSocket message', err);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
socket.on('close', () => {
|
|
46
|
+
traceStore.unsubscribe(socket);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
socket.on('error', (err) => {
|
|
50
|
+
logger.debug('WebSocket client error', err);
|
|
51
|
+
traceStore.unsubscribe(socket);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
logger.info(`WebSocket → ws://localhost:${port}`);
|
|
56
|
+
|
|
57
|
+
return wss;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
createWebSocketServer,
|
|
62
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/** @type {Map<string, { source: string, map: object | null, filename: string }>} */
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
|
|
9
|
+
/** @type {string | null} */
|
|
10
|
+
let projectRoot = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} root
|
|
14
|
+
*/
|
|
15
|
+
function setProjectRoot(root) {
|
|
16
|
+
projectRoot = path.normalize(root);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} filename
|
|
21
|
+
* @param {string} source
|
|
22
|
+
* @param {object | null} map
|
|
23
|
+
*/
|
|
24
|
+
function store(filename, source, map) {
|
|
25
|
+
const key = normalizeKey(filename);
|
|
26
|
+
cache.set(key, {
|
|
27
|
+
filename,
|
|
28
|
+
source,
|
|
29
|
+
map,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} filename
|
|
35
|
+
*/
|
|
36
|
+
function storeOriginal(filename) {
|
|
37
|
+
const key = normalizeKey(filename);
|
|
38
|
+
|
|
39
|
+
if (cache.has(key)) return;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const source = fs.readFileSync(filename, 'utf8');
|
|
43
|
+
cache.set(key, { filename, source, map: null });
|
|
44
|
+
} catch {
|
|
45
|
+
// unreadable source — widget peek will return 404
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a cache entry by project-relative path, absolute path, or basename.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} fileKey
|
|
53
|
+
*/
|
|
54
|
+
function get(fileKey) {
|
|
55
|
+
const direct = cache.get(fileKey);
|
|
56
|
+
if (direct) return direct;
|
|
57
|
+
|
|
58
|
+
const normalized = normalizeKey(fileKey);
|
|
59
|
+
const byNormalized = cache.get(normalized);
|
|
60
|
+
if (byNormalized) return byNormalized;
|
|
61
|
+
|
|
62
|
+
const basename = path.basename(fileKey);
|
|
63
|
+
if (basename !== normalized) {
|
|
64
|
+
return cache.get(basename) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Prefer project-relative paths to avoid basename collisions across folders.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} filename
|
|
74
|
+
*/
|
|
75
|
+
function normalizeKey(filename) {
|
|
76
|
+
const resolved = path.resolve(filename);
|
|
77
|
+
|
|
78
|
+
if (projectRoot) {
|
|
79
|
+
const relative = path.relative(projectRoot, resolved).replace(/\\/g, '/');
|
|
80
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
81
|
+
return relative;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return path.basename(resolved);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} fileKey
|
|
90
|
+
* @param {number} line
|
|
91
|
+
* @param {number} [contextLines]
|
|
92
|
+
*/
|
|
93
|
+
function getSnippet(fileKey, line, contextLines = 4) {
|
|
94
|
+
const entry = get(fileKey);
|
|
95
|
+
if (!entry) return null;
|
|
96
|
+
|
|
97
|
+
const lines = entry.source.split('\n');
|
|
98
|
+
const start = Math.max(0, line - contextLines - 1);
|
|
99
|
+
const end = Math.min(lines.length, line + contextLines);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
file: entry.filename,
|
|
103
|
+
line,
|
|
104
|
+
startLine: start + 1,
|
|
105
|
+
lines: lines.slice(start, end).map((text, index) => ({
|
|
106
|
+
number: start + index + 1,
|
|
107
|
+
text,
|
|
108
|
+
highlight: start + index + 1 === line,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
setProjectRoot,
|
|
115
|
+
store,
|
|
116
|
+
storeOriginal,
|
|
117
|
+
get,
|
|
118
|
+
getSnippet,
|
|
119
|
+
normalizeKey,
|
|
120
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { logger } = require('../lib/logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory store for active and completed trace graphs.
|
|
7
|
+
* @param {number} maxTraces
|
|
8
|
+
*/
|
|
9
|
+
function createTraceStore(maxTraces = 100) {
|
|
10
|
+
/** @type {Map<string, import('../core/tracer').TraceGraph>} */
|
|
11
|
+
const active = new Map();
|
|
12
|
+
|
|
13
|
+
/** @type {import('../core/tracer').TraceGraph[]} */
|
|
14
|
+
const completed = [];
|
|
15
|
+
|
|
16
|
+
/** @type {Set<import('ws').WebSocket>} */
|
|
17
|
+
const subscribers = new Set();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} type
|
|
21
|
+
* @param {import('../core/tracer').TraceGraph} trace
|
|
22
|
+
*/
|
|
23
|
+
function broadcast(type, trace) {
|
|
24
|
+
const message = JSON.stringify({ type, payload: trace });
|
|
25
|
+
|
|
26
|
+
for (const client of subscribers) {
|
|
27
|
+
if (client.readyState !== 1) continue;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
client.send(message);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
logger.debug('WebSocket send failed; removing subscriber', err);
|
|
33
|
+
subscribers.delete(client);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
/**
|
|
40
|
+
* Broadcast a newly discovered in-flight request.
|
|
41
|
+
* @param {import('../core/tracer').TraceGraph} trace
|
|
42
|
+
*/
|
|
43
|
+
start(trace) {
|
|
44
|
+
active.set(trace.id, trace);
|
|
45
|
+
broadcast('trace_start', trace);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stream live progress for an active request.
|
|
50
|
+
* @param {import('../core/tracer').TraceGraph} trace
|
|
51
|
+
*/
|
|
52
|
+
update(trace) {
|
|
53
|
+
if (!active.has(trace.id)) return;
|
|
54
|
+
active.set(trace.id, trace);
|
|
55
|
+
broadcast('trace_update', trace);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Finalise and archive a completed request trace.
|
|
60
|
+
* @param {import('../core/tracer').TraceGraph} trace
|
|
61
|
+
*/
|
|
62
|
+
complete(trace) {
|
|
63
|
+
active.delete(trace.id);
|
|
64
|
+
completed.unshift(trace);
|
|
65
|
+
|
|
66
|
+
if (completed.length > maxTraces) {
|
|
67
|
+
completed.length = maxTraces;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
broadcast('trace', trace);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @deprecated Use complete() — kept for internal compatibility.
|
|
75
|
+
* @param {import('../core/tracer').TraceGraph} trace
|
|
76
|
+
*/
|
|
77
|
+
add(trace) {
|
|
78
|
+
this.complete(trace);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {number} [limit]
|
|
83
|
+
*/
|
|
84
|
+
getAll(limit = 100) {
|
|
85
|
+
const activeTraces = Array.from(active.values()).sort((a, b) => b.timestamp - a.timestamp);
|
|
86
|
+
const merged = [...activeTraces, ...completed];
|
|
87
|
+
return merged.slice(0, limit);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} id
|
|
92
|
+
* @returns {import('../core/tracer').TraceGraph | undefined}
|
|
93
|
+
*/
|
|
94
|
+
getById(id) {
|
|
95
|
+
if (active.has(id)) return active.get(id);
|
|
96
|
+
return completed.find((trace) => trace.id === id);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {import('ws').WebSocket} client
|
|
101
|
+
*/
|
|
102
|
+
subscribe(client) {
|
|
103
|
+
subscribers.add(client);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {import('ws').WebSocket} client
|
|
108
|
+
*/
|
|
109
|
+
unsubscribe(client) {
|
|
110
|
+
subscribers.delete(client);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
createTraceStore,
|
|
117
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const parser = require('@babel/parser');
|
|
5
|
+
const traverse = require('@babel/traverse').default;
|
|
6
|
+
const generate = require('@babel/generator').default;
|
|
7
|
+
const t = require('@babel/types');
|
|
8
|
+
|
|
9
|
+
const PROBE_MODULE = path.join(__dirname, 'probe.js');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} filename
|
|
13
|
+
* @param {number} line
|
|
14
|
+
* @param {string} kind
|
|
15
|
+
* @param {string} [projectRoot]
|
|
16
|
+
*/
|
|
17
|
+
function probeLabel(filename, line, kind, projectRoot) {
|
|
18
|
+
const resolved = path.resolve(filename);
|
|
19
|
+
let relative = path.basename(resolved);
|
|
20
|
+
|
|
21
|
+
if (projectRoot) {
|
|
22
|
+
const fromRoot = path.relative(projectRoot, resolved).replace(/\\/g, '/');
|
|
23
|
+
if (fromRoot && !fromRoot.startsWith('..') && !path.isAbsolute(fromRoot)) {
|
|
24
|
+
relative = fromRoot;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return `${kind}:${relative}:${line}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} source
|
|
33
|
+
* @param {import('@babel/types').Node} node
|
|
34
|
+
*/
|
|
35
|
+
function sliceFromSource(source, node) {
|
|
36
|
+
if (typeof node.start === 'number' && typeof node.end === 'number') {
|
|
37
|
+
return source.slice(node.start, node.end).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} label
|
|
45
|
+
* @param {import('@babel/types').Expression} expression
|
|
46
|
+
* @param {string | null} conditionText
|
|
47
|
+
* @param {boolean} [hasElse]
|
|
48
|
+
*/
|
|
49
|
+
function probeCall(label, expression, conditionText, hasElse = false) {
|
|
50
|
+
const args = [t.stringLiteral(label), expression];
|
|
51
|
+
|
|
52
|
+
if (conditionText) {
|
|
53
|
+
args.push(t.stringLiteral(conditionText));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
args.push(t.booleanLiteral(hasElse));
|
|
57
|
+
|
|
58
|
+
return t.callExpression(t.identifier('__rg_probe'), args);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} label
|
|
63
|
+
*/
|
|
64
|
+
function elseProbeStatement(label) {
|
|
65
|
+
return t.expressionStatement(
|
|
66
|
+
t.callExpression(t.identifier('__rg_else_probe'), [t.stringLiteral(label)])
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} source
|
|
72
|
+
* @param {string} filename
|
|
73
|
+
* @param {{ projectRoot?: string }} [options]
|
|
74
|
+
* @returns {{ code: string, map: object | null, skipped: boolean, reason?: string }}
|
|
75
|
+
*/
|
|
76
|
+
function transformSource(source, filename, options = {}) {
|
|
77
|
+
const projectRoot = options.projectRoot;
|
|
78
|
+
if (source.includes('opencons-skip') || source.includes('routegrapher-skip')) {
|
|
79
|
+
return { code: source, map: null, skipped: true, reason: 'opencons-skip' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isLikelyMinified(source)) {
|
|
83
|
+
return { code: source, map: null, skipped: true, reason: 'minified' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let ast;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
ast = parser.parse(source, {
|
|
90
|
+
sourceType: 'script',
|
|
91
|
+
plugins: ['jsx', 'classProperties', 'optionalChaining', 'nullishCoalescingOperator'],
|
|
92
|
+
errorRecovery: true,
|
|
93
|
+
ranges: true,
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { code: source, map: null, skipped: true, reason: `parse-error: ${err.message}` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const probeImportPath = PROBE_MODULE.replace(/\\/g, '/');
|
|
100
|
+
|
|
101
|
+
traverse(ast, {
|
|
102
|
+
Program(programPath) {
|
|
103
|
+
const body = programPath.node.body;
|
|
104
|
+
|
|
105
|
+
if (!hasProbeImport(body)) {
|
|
106
|
+
body.unshift(
|
|
107
|
+
t.variableDeclaration('const', [
|
|
108
|
+
t.variableDeclarator(
|
|
109
|
+
t.objectPattern([
|
|
110
|
+
t.objectProperty(t.identifier('__rg_probe'), t.identifier('__rg_probe'), false, true),
|
|
111
|
+
t.objectProperty(
|
|
112
|
+
t.identifier('__rg_else_probe'),
|
|
113
|
+
t.identifier('__rg_else_probe'),
|
|
114
|
+
false,
|
|
115
|
+
true
|
|
116
|
+
),
|
|
117
|
+
t.objectProperty(
|
|
118
|
+
t.identifier('__rg_catch_probe'),
|
|
119
|
+
t.identifier('__rg_catch_probe'),
|
|
120
|
+
false,
|
|
121
|
+
true
|
|
122
|
+
),
|
|
123
|
+
]),
|
|
124
|
+
t.callExpression(t.identifier('require'), [t.stringLiteral(probeImportPath)])
|
|
125
|
+
),
|
|
126
|
+
])
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
IfStatement(ifPath) {
|
|
132
|
+
const line = ifPath.node.loc?.start.line || 0;
|
|
133
|
+
const label = probeLabel(filename, line, 'if', projectRoot);
|
|
134
|
+
const test = ifPath.node.test;
|
|
135
|
+
const conditionText = sliceFromSource(source, test);
|
|
136
|
+
const hasElse = Boolean(ifPath.node.alternate);
|
|
137
|
+
ifPath.node.test = probeCall(label, test, conditionText, hasElse);
|
|
138
|
+
|
|
139
|
+
if (ifPath.node.alternate) {
|
|
140
|
+
if (t.isBlockStatement(ifPath.node.alternate)) {
|
|
141
|
+
ifPath.node.alternate.body.unshift(elseProbeStatement(label));
|
|
142
|
+
} else {
|
|
143
|
+
ifPath.node.alternate = t.blockStatement([
|
|
144
|
+
elseProbeStatement(label),
|
|
145
|
+
t.isStatement(ifPath.node.alternate)
|
|
146
|
+
? ifPath.node.alternate
|
|
147
|
+
: t.expressionStatement(ifPath.node.alternate),
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
ConditionalExpression(condPath) {
|
|
154
|
+
const line = condPath.node.loc?.start.line || 0;
|
|
155
|
+
const label = probeLabel(filename, line, 'ternary', projectRoot);
|
|
156
|
+
const test = condPath.node.test;
|
|
157
|
+
condPath.node.test = probeCall(label, test, sliceFromSource(source, test), true);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
SwitchStatement(switchPath) {
|
|
161
|
+
const line = switchPath.node.loc?.start.line || 0;
|
|
162
|
+
const label = probeLabel(filename, line, 'switch', projectRoot);
|
|
163
|
+
const discriminant = switchPath.node.discriminant;
|
|
164
|
+
switchPath.node.discriminant = probeCall(
|
|
165
|
+
label,
|
|
166
|
+
discriminant,
|
|
167
|
+
sliceFromSource(source, discriminant),
|
|
168
|
+
false
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
WhileStatement(whilePath) {
|
|
173
|
+
const line = whilePath.node.loc?.start.line || 0;
|
|
174
|
+
const label = probeLabel(filename, line, 'while', projectRoot);
|
|
175
|
+
const test = whilePath.node.test;
|
|
176
|
+
whilePath.node.test = probeCall(label, test, sliceFromSource(source, test), false);
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
DoWhileStatement(doWhilePath) {
|
|
180
|
+
const line = doWhilePath.node.loc?.start.line || 0;
|
|
181
|
+
const label = probeLabel(filename, line, 'while', projectRoot);
|
|
182
|
+
const test = doWhilePath.node.test;
|
|
183
|
+
doWhilePath.node.test = probeCall(label, test, sliceFromSource(source, test), false);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
ForStatement(forPath) {
|
|
187
|
+
if (!forPath.node.test) return;
|
|
188
|
+
const line = forPath.node.loc?.start.line || 0;
|
|
189
|
+
const label = probeLabel(filename, line, 'for', projectRoot);
|
|
190
|
+
const test = forPath.node.test;
|
|
191
|
+
forPath.node.test = probeCall(label, test, sliceFromSource(source, test), false);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
CatchClause(catchPath) {
|
|
195
|
+
const line = catchPath.node.loc?.start.line || 0;
|
|
196
|
+
const label = probeLabel(filename, line, 'catch', projectRoot);
|
|
197
|
+
const paramName = t.isIdentifier(catchPath.node.param)
|
|
198
|
+
? catchPath.node.param.name
|
|
199
|
+
: 'err';
|
|
200
|
+
|
|
201
|
+
catchPath.node.body.body.unshift(
|
|
202
|
+
t.expressionStatement(
|
|
203
|
+
t.callExpression(t.identifier('__rg_catch_probe'), [
|
|
204
|
+
t.stringLiteral(label),
|
|
205
|
+
t.identifier(paramName),
|
|
206
|
+
])
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const output = generate(ast, {
|
|
213
|
+
sourceMaps: true,
|
|
214
|
+
sourceFileName: filename,
|
|
215
|
+
}, source);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
code: output.code,
|
|
219
|
+
map: output.map,
|
|
220
|
+
skipped: false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {import('@babel/types').Statement[]} body
|
|
226
|
+
*/
|
|
227
|
+
function hasProbeImport(body) {
|
|
228
|
+
return body.some(
|
|
229
|
+
(stmt) =>
|
|
230
|
+
t.isVariableDeclaration(stmt) &&
|
|
231
|
+
stmt.declarations.some(
|
|
232
|
+
(decl) =>
|
|
233
|
+
t.isObjectPattern(decl.id) &&
|
|
234
|
+
decl.id.properties.some(
|
|
235
|
+
(prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: '__rg_probe' })
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} source
|
|
243
|
+
*/
|
|
244
|
+
function isLikelyMinified(source) {
|
|
245
|
+
const lines = source.split('\n');
|
|
246
|
+
if (lines.length < 3 && source.length > 500) return true;
|
|
247
|
+
|
|
248
|
+
const avgLine = source.length / Math.max(lines.length, 1);
|
|
249
|
+
return avgLine > 300;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
transformSource,
|
|
254
|
+
probeLabel,
|
|
255
|
+
};
|