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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +382 -0
  3. package/opencons.d.ts +55 -0
  4. package/package.json +73 -0
  5. package/scripts/vendor-d3.js +22 -0
  6. package/src/core/context.js +44 -0
  7. package/src/core/index.js +198 -0
  8. package/src/core/tracer.js +252 -0
  9. package/src/drivers/db-language.js +207 -0
  10. package/src/drivers/detect.js +62 -0
  11. package/src/drivers/drizzle.js +87 -0
  12. package/src/drivers/index.js +43 -0
  13. package/src/drivers/mongoose.js +89 -0
  14. package/src/drivers/mysql2.js +116 -0
  15. package/src/drivers/pg.js +130 -0
  16. package/src/drivers/prisma.js +109 -0
  17. package/src/drivers/record.js +158 -0
  18. package/src/index.js +28 -0
  19. package/src/integrations/nest-lifecycle.js +357 -0
  20. package/src/integrations/nest.js +89 -0
  21. package/src/interceptors/express.js +270 -0
  22. package/src/interceptors/require-hook.js +109 -0
  23. package/src/lib/config.js +139 -0
  24. package/src/lib/errors.js +54 -0
  25. package/src/lib/http-response.js +37 -0
  26. package/src/lib/logger.js +69 -0
  27. package/src/lib/serialize.js +22 -0
  28. package/src/server/static.js +165 -0
  29. package/src/server/ws.js +62 -0
  30. package/src/store/source-cache.js +120 -0
  31. package/src/store/trace-store.js +117 -0
  32. package/src/transform/ast.js +255 -0
  33. package/src/transform/natural-language.js +146 -0
  34. package/src/transform/probe.js +161 -0
  35. package/src/transform/register.js +44 -0
  36. package/src/utils/label.js +26 -0
  37. package/src/utils/observable.js +103 -0
  38. package/widget/app.js +356 -0
  39. package/widget/db-language.js +90 -0
  40. package/widget/graph.js +1167 -0
  41. package/widget/index.html +132 -0
  42. package/widget/styles.css +773 -0
  43. package/widget/timeline.js +57 -0
  44. package/widget/vendor/d3.min.js +2 -0
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ const { getCurrentTracer } = require('../core/context');
4
+
5
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'all'];
6
+
7
+ let globalPatchApplied = false;
8
+
9
+ /**
10
+ * Patch Express Application and Router prototypes so every handler
11
+ * registered after Opencons initialises is automatically wrapped.
12
+ */
13
+ function patchExpressGlobally() {
14
+ if (globalPatchApplied) return;
15
+ globalPatchApplied = true;
16
+
17
+ const express = require('express');
18
+
19
+ patchLayer(express.application);
20
+ patchRouterPrototype(express.Router.prototype);
21
+ }
22
+
23
+ /**
24
+ * @deprecated Use patchExpressGlobally — kept for explicit per-app attachment.
25
+ * @param {import('express').Application} app
26
+ */
27
+ function patchExpressApp(app) {
28
+ patchExpressGlobally();
29
+ patchLayer(app);
30
+ }
31
+
32
+ /**
33
+ * @param {import('express').Application | import('express').Router} target
34
+ */
35
+ function patchLayer(target) {
36
+ if (target.__openconsLayerPatched) return;
37
+ target.__openconsLayerPatched = true;
38
+
39
+ const originalUse = target.use;
40
+ target.use = function patchedUse(...args) {
41
+ const wrapped = wrapHandlers(args);
42
+
43
+ for (const fn of wrapped) {
44
+ if (fn && fn.__openconsEntry) {
45
+ patchLayer(this);
46
+ }
47
+ }
48
+
49
+ return originalUse.apply(this, wrapped);
50
+ };
51
+
52
+ for (const method of HTTP_METHODS) {
53
+ if (typeof target[method] !== 'function') continue;
54
+
55
+ const original = target[method];
56
+ target[method] = function patchedMethod(...args) {
57
+ const wrapped = wrapHandlers(args);
58
+ return original.apply(this, wrapped);
59
+ };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {Function} Router
65
+ */
66
+ function patchRouterPrototype(Router) {
67
+ if (Router.__openconsPatched) return;
68
+ Router.__openconsPatched = true;
69
+
70
+ const originalUse = Router.use;
71
+ Router.use = function patchedRouterUse(...args) {
72
+ const wrapped = wrapHandlers(args);
73
+ return originalUse.apply(this, wrapped);
74
+ };
75
+
76
+ for (const method of HTTP_METHODS) {
77
+ if (typeof Router[method] !== 'function') continue;
78
+
79
+ const original = Router[method];
80
+ Router[method] = function patchedRouterMethod(...args) {
81
+ const wrapped = wrapHandlers(args);
82
+ return original.apply(this, wrapped);
83
+ };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {unknown[]} args
89
+ * @returns {unknown[]}
90
+ */
91
+ function wrapHandlers(args) {
92
+ const result = [...args];
93
+ const startIndex = typeof result[0] === 'string' || result[0] instanceof RegExp ? 1 : 0;
94
+
95
+ for (let i = startIndex; i < result.length; i += 1) {
96
+ const handler = result[i];
97
+
98
+ if (typeof handler === 'function') {
99
+ result[i] = wrapHandler(handler);
100
+ } else if (Array.isArray(handler)) {
101
+ result[i] = handler.map((fn) =>
102
+ typeof fn === 'function' ? wrapHandler(fn) : fn
103
+ );
104
+ }
105
+ }
106
+
107
+ return result;
108
+ }
109
+
110
+ /**
111
+ * @param {Function} handler
112
+ * @returns {Function}
113
+ */
114
+ function wrapHandler(handler) {
115
+ if (handler.__openconsWrapped) return handler;
116
+
117
+ const name = resolveHandlerName(handler);
118
+
119
+ function wrapped(req, res, next) {
120
+ const tracer = getCurrentTracer();
121
+
122
+ if (!tracer) {
123
+ return handler(req, res, next);
124
+ }
125
+
126
+ const entered = performance.now();
127
+ let calledNext = false;
128
+ let exitReason = null;
129
+ let exited = false;
130
+
131
+ const wrappedNext = (...nextArgs) => {
132
+ calledNext = true;
133
+
134
+ if (!exited) {
135
+ exited = true;
136
+ recordMiddlewareExit();
137
+ }
138
+
139
+ return next(...nextArgs);
140
+ };
141
+
142
+ const recordMiddlewareExit = () => {
143
+ const duration_ms = Math.round((performance.now() - entered) * 10) / 10;
144
+
145
+ tracer.addNode({
146
+ type: 'middleware',
147
+ label: name,
148
+ duration_ms,
149
+ called_next: calledNext,
150
+ exit_reason: exitReason || undefined,
151
+ });
152
+ };
153
+
154
+ const originalStatus = res.status.bind(res);
155
+ res.status = function patchedStatus(code) {
156
+ if (!calledNext && !exited) {
157
+ exitReason = `res.status(${code})`;
158
+ }
159
+ return originalStatus(code);
160
+ };
161
+
162
+ const originalJson = res.json.bind(res);
163
+ res.json = function patchedJson(body) {
164
+ if (!calledNext && !exited) {
165
+ exitReason = 'res.json(...)';
166
+ }
167
+ return originalJson(body);
168
+ };
169
+
170
+ const originalSend = res.send.bind(res);
171
+ res.send = function patchedSend(body) {
172
+ if (!calledNext && !exited) {
173
+ exitReason = 'res.send(...)';
174
+ }
175
+ return originalSend(body);
176
+ };
177
+
178
+ const originalEnd = res.end.bind(res);
179
+ res.end = function patchedEnd(...endArgs) {
180
+ if (!calledNext && !exited) {
181
+ exitReason = 'res.end(...)';
182
+ exited = true;
183
+ recordMiddlewareExit();
184
+ }
185
+ return originalEnd(...endArgs);
186
+ };
187
+
188
+ try {
189
+ const result = handler(req, res, wrappedNext);
190
+
191
+ if (result && typeof result.then === 'function') {
192
+ result
193
+ .catch((err) => {
194
+ if (!exited) {
195
+ exitReason = `error: ${err.message}`;
196
+ exited = true;
197
+ recordMiddlewareExit();
198
+ }
199
+ if (!calledNext) next(err);
200
+ })
201
+ .finally(() => {
202
+ if (!exited) {
203
+ exited = true;
204
+ recordMiddlewareExit();
205
+ }
206
+ });
207
+ } else if (!exited && !res.headersSent && !calledNext) {
208
+ // Synchronous handler that did not call next or send a response yet.
209
+ // Defer exit recording until response is sent or next is called.
210
+ }
211
+
212
+ return result;
213
+ } catch (err) {
214
+ if (!exited) {
215
+ exitReason = `error: ${err.message}`;
216
+ exited = true;
217
+ recordMiddlewareExit();
218
+ }
219
+ throw err;
220
+ }
221
+ }
222
+
223
+ wrapped.__openconsWrapped = true;
224
+ wrapped.__openconsName = name;
225
+
226
+ return wrapped;
227
+ }
228
+
229
+ /**
230
+ * @param {Function} handler
231
+ */
232
+ function resolveHandlerName(handler) {
233
+ if (handler.__openconsName) return handler.__openconsName;
234
+
235
+ const ctor = handler.constructor?.name;
236
+ let method = handler.name;
237
+
238
+ if (method && method.startsWith('bound ')) {
239
+ method = method.slice('bound '.length);
240
+ }
241
+
242
+ if (ctor && ctor !== 'Function' && ctor !== 'Object') {
243
+ if (method && method !== 'constructor' && method !== 'anonymous') {
244
+ if (method === 'use' || method === 'handle' || method === 'handleRequest') {
245
+ return ctor;
246
+ }
247
+ return `${ctor}.${method}`;
248
+ }
249
+ return ctor;
250
+ }
251
+
252
+ if (method && method !== 'anonymous') {
253
+ return method;
254
+ }
255
+
256
+ const str = Function.prototype.toString.call(handler);
257
+ const match =
258
+ str.match(/^async\s+function\s+(\w+)/) ||
259
+ str.match(/^function\s+(\w+)/) ||
260
+ str.match(/(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:function|\()/);
261
+ if (match) return match[1];
262
+
263
+ return 'anonymous';
264
+ }
265
+
266
+ module.exports = {
267
+ patchExpressGlobally,
268
+ patchExpressApp,
269
+ wrapHandler,
270
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const Module = require('module');
6
+ const { transformSource } = require('../transform/ast');
7
+ const sourceCache = require('../store/source-cache');
8
+
9
+ const OPENCONS_PKG = path.normalize(path.join(__dirname, '..', '..'));
10
+
11
+ /** @type {boolean} */
12
+ let hookInstalled = false;
13
+
14
+ /** @type {((filename: string) => boolean) | null} */
15
+ let shouldTransformFile = null;
16
+
17
+ /**
18
+ * @param {object} options
19
+ * @param {string[]} [options.exclude]
20
+ * @param {string} [options.projectRoot]
21
+ */
22
+ function installRequireHook(options = {}) {
23
+ if (hookInstalled) return;
24
+ hookInstalled = true;
25
+
26
+ const projectRoot = options.projectRoot || process.cwd();
27
+ sourceCache.setProjectRoot(projectRoot);
28
+
29
+ shouldTransformFile = createFilter(projectRoot, options.exclude || []);
30
+
31
+ const originalJsHandler = Module._extensions['.js'];
32
+
33
+ Module._extensions['.js'] = function OpenconsJsExtension(module, filename) {
34
+ if (!shouldTransformFile(filename)) {
35
+ return originalJsHandler(module, filename);
36
+ }
37
+
38
+ let source;
39
+
40
+ try {
41
+ source = fs.readFileSync(filename, 'utf8');
42
+ } catch (err) {
43
+ return originalJsHandler(module, filename);
44
+ }
45
+
46
+ sourceCache.storeOriginal(filename);
47
+
48
+ const result = transformSource(source, filename, { projectRoot });
49
+
50
+ if (result.skipped) {
51
+ sourceCache.store(filename, source, null);
52
+ return module._compile(source, filename);
53
+ }
54
+
55
+ sourceCache.store(filename, source, result.map);
56
+
57
+ return module._compile(result.code, filename);
58
+ };
59
+
60
+ const { logger } = require('../lib/logger');
61
+ logger.info('Source transform hook installed (CommonJS .js)');
62
+ }
63
+
64
+ /**
65
+ * @param {string} projectRoot
66
+ * @param {string[]} excludePatterns
67
+ */
68
+ function createFilter(projectRoot, excludePatterns) {
69
+ const normalizedRoot = path.normalize(projectRoot);
70
+
71
+ return function filter(filename) {
72
+ const normalized = path.normalize(filename);
73
+
74
+ if (normalized.includes(`${path.sep}node_modules${path.sep}`)) {
75
+ return false;
76
+ }
77
+
78
+ if (normalized.startsWith(OPENCONS_PKG)) {
79
+ return false;
80
+ }
81
+
82
+ if (!normalized.startsWith(normalizedRoot)) {
83
+ return false;
84
+ }
85
+
86
+ if (!normalized.endsWith('.js')) {
87
+ return false;
88
+ }
89
+
90
+ const relative = path.relative(normalizedRoot, normalized);
91
+
92
+ return !excludePatterns.some((pattern) => matchGlob(relative, pattern));
93
+ };
94
+ }
95
+
96
+ /**
97
+ * @param {string} value
98
+ * @param {string} pattern
99
+ */
100
+ function matchGlob(value, pattern) {
101
+ const regex = new RegExp(
102
+ `^${pattern.replace(/\*/g, '.*').replace(/\//g, '[\\\\/]').replace(/\\/g, '\\\\')}$`
103
+ );
104
+ return regex.test(value);
105
+ }
106
+
107
+ module.exports = {
108
+ installRequireHook,
109
+ };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const { ConfigurationError } = require('./errors');
4
+
5
+ /** @typedef {import('../core/tracer').TraceGraph} TraceGraph */
6
+
7
+ /**
8
+ * @typedef {Object} OpenconsOptions
9
+ * @property {number} port
10
+ * @property {boolean | undefined} enabled
11
+ * @property {boolean} enableWidget
12
+ * @property {string[]} exclude
13
+ * @property {boolean} captureBody
14
+ * @property {boolean} captureResponse
15
+ * @property {number} maxTraces
16
+ * @property {Record<'mongoose' | 'drizzle' | 'pg' | 'prisma' | 'mysql2', boolean>} drivers
17
+ * @property {{ enabled: boolean, projectRoot: string | undefined, exclude: string[] }} transform
18
+ * @property {number} [widgetPort]
19
+ */
20
+
21
+ const DEFAULT_OPTIONS = {
22
+ port: 7331,
23
+ enabled: undefined,
24
+ enableWidget: true,
25
+ exclude: [],
26
+ captureBody: false,
27
+ captureResponse: false,
28
+ maxTraces: 100,
29
+ drivers: {
30
+ mongoose: true,
31
+ drizzle: true,
32
+ pg: true,
33
+ prisma: true,
34
+ mysql2: true,
35
+ },
36
+ transform: {
37
+ enabled: false,
38
+ projectRoot: undefined,
39
+ exclude: [],
40
+ },
41
+ };
42
+
43
+ /**
44
+ * @param {unknown} value
45
+ * @param {string} field
46
+ * @returns {number}
47
+ */
48
+ function requirePositiveInt(value, field) {
49
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
50
+ throw new ConfigurationError(`${field} must be a positive integer`);
51
+ }
52
+ return value;
53
+ }
54
+
55
+ /**
56
+ * @param {unknown} value
57
+ * @param {string} field
58
+ * @returns {string[]}
59
+ */
60
+ function requireStringArray(value, field) {
61
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
62
+ throw new ConfigurationError(`${field} must be an array of strings`);
63
+ }
64
+ return value;
65
+ }
66
+
67
+ /**
68
+ * Merge user options with defaults and validate types.
69
+ *
70
+ * @param {Partial<OpenconsOptions>} [userOptions]
71
+ * @returns {OpenconsOptions}
72
+ */
73
+ function resolveOptions(userOptions = {}) {
74
+ const options = {
75
+ ...DEFAULT_OPTIONS,
76
+ ...userOptions,
77
+ drivers: {
78
+ ...DEFAULT_OPTIONS.drivers,
79
+ ...(userOptions.drivers || {}),
80
+ },
81
+ transform: {
82
+ ...DEFAULT_OPTIONS.transform,
83
+ ...(userOptions.transform || {}),
84
+ },
85
+ };
86
+
87
+ options.port = requirePositiveInt(options.port, 'port');
88
+ options.maxTraces = requirePositiveInt(options.maxTraces, 'maxTraces');
89
+ options.exclude = requireStringArray(options.exclude, 'exclude');
90
+ options.transform.exclude = requireStringArray(options.transform.exclude, 'transform.exclude');
91
+
92
+ if (typeof options.enableWidget !== 'boolean') {
93
+ throw new ConfigurationError('enableWidget must be a boolean');
94
+ }
95
+
96
+ if (typeof options.captureBody !== 'boolean') {
97
+ throw new ConfigurationError('captureBody must be a boolean');
98
+ }
99
+
100
+ if (typeof options.captureResponse !== 'boolean') {
101
+ throw new ConfigurationError('captureResponse must be a boolean');
102
+ }
103
+
104
+ if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
105
+ throw new ConfigurationError('enabled must be a boolean when provided');
106
+ }
107
+
108
+ for (const [driver, enabled] of Object.entries(options.drivers)) {
109
+ if (typeof enabled !== 'boolean') {
110
+ throw new ConfigurationError(`drivers.${driver} must be a boolean`);
111
+ }
112
+ }
113
+
114
+ if (typeof options.transform.enabled !== 'boolean') {
115
+ throw new ConfigurationError('transform.enabled must be a boolean');
116
+ }
117
+
118
+ if (
119
+ options.transform.projectRoot !== undefined &&
120
+ typeof options.transform.projectRoot !== 'string'
121
+ ) {
122
+ throw new ConfigurationError('transform.projectRoot must be a string when provided');
123
+ }
124
+
125
+ return options;
126
+ }
127
+
128
+ /**
129
+ * @returns {boolean}
130
+ */
131
+ function isProductionDisabled(options) {
132
+ return process.env.NODE_ENV === 'production' && options.enabled !== true;
133
+ }
134
+
135
+ module.exports = {
136
+ DEFAULT_OPTIONS,
137
+ resolveOptions,
138
+ isProductionDisabled,
139
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ class OpenconsError extends Error {
4
+ /**
5
+ * @param {string} message
6
+ * @param {string} [code]
7
+ */
8
+ constructor(message, code = 'OPENCONS_ERROR') {
9
+ super(message);
10
+ this.name = 'OpenconsError';
11
+ this.code = code;
12
+ }
13
+ }
14
+
15
+ class ConfigurationError extends OpenconsError {
16
+ /**
17
+ * @param {string} message
18
+ */
19
+ constructor(message) {
20
+ super(message, 'CONFIGURATION_ERROR');
21
+ this.name = 'ConfigurationError';
22
+ }
23
+ }
24
+
25
+ class WidgetServerError extends OpenconsError {
26
+ /**
27
+ * @param {string} message
28
+ * @param {NodeJS.ErrnoException | null} [cause]
29
+ */
30
+ constructor(message, cause = null) {
31
+ super(message, 'WIDGET_SERVER_ERROR');
32
+ this.name = 'WidgetServerError';
33
+ if (cause) {
34
+ this.cause = cause;
35
+ }
36
+ }
37
+ }
38
+
39
+ class WebSocketError extends OpenconsError {
40
+ /**
41
+ * @param {string} message
42
+ */
43
+ constructor(message) {
44
+ super(message, 'WEBSOCKET_ERROR');
45
+ this.name = 'WebSocketError';
46
+ }
47
+ }
48
+
49
+ module.exports = {
50
+ OpenconsError,
51
+ ConfigurationError,
52
+ WidgetServerError,
53
+ WebSocketError,
54
+ };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @param {import('http').ServerResponse} res
5
+ * @param {number} status
6
+ * @param {string} body
7
+ * @param {string} [contentType]
8
+ */
9
+ function sendText(res, status, body, contentType = 'text/plain; charset=utf-8') {
10
+ res.writeHead(status, { 'Content-Type': contentType });
11
+ res.end(body);
12
+ }
13
+
14
+ /**
15
+ * @param {import('http').ServerResponse} res
16
+ * @param {number} status
17
+ * @param {{ error: string, code?: string }} payload
18
+ */
19
+ function sendJsonError(res, status, payload) {
20
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
21
+ res.end(JSON.stringify(payload));
22
+ }
23
+
24
+ /**
25
+ * @param {import('http').ServerResponse} res
26
+ * @param {unknown} payload
27
+ */
28
+ function sendJson(res, payload) {
29
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
30
+ res.end(JSON.stringify(payload));
31
+ }
32
+
33
+ module.exports = {
34
+ sendText,
35
+ sendJsonError,
36
+ sendJson,
37
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /** @typedef {'debug' | 'info' | 'warn' | 'error'} LogLevel */
4
+
5
+ const LEVELS = /** @type {const} */ ({ debug: 0, info: 1, warn: 2, error: 3 });
6
+
7
+ /** @type {LogLevel} */
8
+ const logLevel = process.env.OPENCONS_LOG_LEVEL || process.env.ROUTEGRAPHER_LOG_LEVEL;
9
+ let minLevel = logLevel === 'debug' ? 'debug' : 'info';
10
+
11
+ const PREFIX = '[Opencons]';
12
+
13
+ /**
14
+ * @param {LogLevel} level
15
+ * @param {string} message
16
+ * @param {unknown} [detail]
17
+ */
18
+ function write(level, message, detail) {
19
+ if (LEVELS[level] < LEVELS[minLevel]) return;
20
+
21
+ const line = `${PREFIX} ${message}`;
22
+ const sink =
23
+ level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
24
+
25
+ if (detail !== undefined) {
26
+ sink(line, detail);
27
+ return;
28
+ }
29
+
30
+ sink(line);
31
+ }
32
+
33
+ const logger = {
34
+ /**
35
+ * @param {LogLevel} level
36
+ */
37
+ setLevel(level) {
38
+ if (level in LEVELS) {
39
+ minLevel = level;
40
+ }
41
+ },
42
+
43
+ /** @returns {LogLevel} */
44
+ getLevel() {
45
+ return minLevel;
46
+ },
47
+
48
+ /** @param {string} message @param {unknown} [detail] */
49
+ debug(message, detail) {
50
+ write('debug', message, detail);
51
+ },
52
+
53
+ /** @param {string} message @param {unknown} [detail] */
54
+ info(message, detail) {
55
+ write('info', message, detail);
56
+ },
57
+
58
+ /** @param {string} message @param {unknown} [detail] */
59
+ warn(message, detail) {
60
+ write('warn', message, detail);
61
+ },
62
+
63
+ /** @param {string} message @param {unknown} [detail] */
64
+ error(message, detail) {
65
+ write('error', message, detail);
66
+ },
67
+ };
68
+
69
+ module.exports = { logger };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deep-clone a value for trace snapshots. Falls back to a placeholder when
5
+ * JSON serialization fails (circular refs, BigInt, etc.).
6
+ *
7
+ * @param {unknown} value
8
+ * @returns {unknown}
9
+ */
10
+ function safeClone(value) {
11
+ if (value === undefined || value === null) return value;
12
+
13
+ try {
14
+ return JSON.parse(JSON.stringify(value));
15
+ } catch {
16
+ return '[unserializable]';
17
+ }
18
+ }
19
+
20
+ module.exports = {
21
+ safeClone,
22
+ };