diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js index 6bf677d09..fbf4776d5 100644 --- a/packages/bruno-app/src/components/Devtools/Console/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/index.js @@ -64,80 +64,106 @@ const LogTimestamp = ({ timestamp }) => { return {time}; }; +// Helper function to check if an object is a plain object (not a class instance) +const isPlainObject = (obj) => { + if (typeof obj !== 'object' || obj === null) return false; + const proto = Object.getPrototypeOf(obj); + return proto === null || proto === Object.prototype; +}; + +// Helper function to transform Bruno special types back to readable format +// Extracted outside component to avoid recreation on every render +const transformBrunoTypes = (obj, seen = new WeakSet()) => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + // Guard against circular references + if (seen.has(obj)) { + return '[Circular]'; + } + seen.add(obj); + + // Handle Bruno special types + if (obj.__brunoType) { + switch (obj.__brunoType) { + case 'Set': + // Transform Set to display values at top level with numeric indices + if (Array.isArray(obj.__brunoValue)) { + return Object.fromEntries( + obj.__brunoValue.map((value, index) => [index, transformBrunoTypes(value, seen)]) + ); + } + return {}; + case 'Map': + // Transform Map to display entries at top level with => notation + if (Array.isArray(obj.__brunoValue)) { + const mapEntries = {}; + for (const entry of obj.__brunoValue) { + // Defensive check: ensure entry is a valid [key, value] pair + if (Array.isArray(entry) && entry.length >= 2) { + const [key, value] = entry; + mapEntries[`${String(key)} =>`] = transformBrunoTypes(value, seen); + } + } + return mapEntries; + } + return {}; + case 'Function': + return `[Function: ${obj.__brunoValue?.split?.('\n')?.[0]?.substring(0, 50) ?? 'anonymous'}...]`; + case 'undefined': + return 'undefined'; + default: + return obj; + } + } + + // Handle arrays - recurse into elements + if (Array.isArray(obj)) { + return obj.map((item) => transformBrunoTypes(item, seen)); + } + + // Preserve non-plain objects (Date, Error, RegExp, class instances, etc.) + if (!isPlainObject(obj)) { + return obj; + } + + // Only deep-clone plain objects + const transformed = {}; + for (const [key, value] of Object.entries(obj)) { + transformed[key] = transformBrunoTypes(value, seen); + } + return transformed; +}; + +// Helper to get metadata about Bruno types for display purposes +const getBrunoTypeMetadata = (obj) => { + if (typeof obj !== 'object' || obj === null) { + return {}; + } + if (obj.__brunoType === 'Set' || obj.__brunoType === 'Map') { + return { type: obj.__brunoType }; + } + return {}; +}; + const LogMessage = ({ message, args }) => { const { displayedTheme } = useTheme(); - // Helper function to transform Bruno special types back to readable format - // Returns { data, metadata } where metadata contains type information - const transformBrunoTypes = (obj, returnMetadata = false) => { - if (typeof obj !== 'object' || obj === null) { - return returnMetadata ? { data: obj, metadata: {} } : obj; - } - - // Handle Bruno special types - if (obj.__brunoType) { - switch (obj.__brunoType) { - case 'Set': - // Transform Set to display values at top level with numeric indices - // Convert array of values to object with numeric keys (0, 1, 2, ...) - const setEntries = {}; - if (Array.isArray(obj.__brunoValue)) { - obj.__brunoValue.forEach((value, index) => { - setEntries[index] = transformBrunoTypes(value, false); - }); - } - return returnMetadata ? { data: setEntries, metadata: { type: 'Set' } } : setEntries; - case 'Map': - // Transform Map to display entries at top level with => notation - // Convert array of [key, value] pairs to object with "key => value" format - const mapEntries = {}; - if (Array.isArray(obj.__brunoValue)) { - obj.__brunoValue.forEach(([key, value]) => { - // Use => notation to clearly indicate Map entries - const displayKey = `${String(key)} =>`; - mapEntries[displayKey] = transformBrunoTypes(value, false); - }); - } - return returnMetadata ? { data: mapEntries, metadata: { type: 'Map' } } : mapEntries; - case 'Function': - const funcData = `[Function: ${obj.__brunoValue.split('\n')[0].substring(0, 50)}...]`; - return returnMetadata ? { data: funcData, metadata: {} } : funcData; - case 'undefined': - return returnMetadata ? { data: 'undefined', metadata: {} } : 'undefined'; - default: - return returnMetadata ? { data: obj, metadata: {} } : obj; - } - } - - // Recursively transform nested objects - if (Array.isArray(obj)) { - const transformed = obj.map((item) => transformBrunoTypes(item, false)); - return returnMetadata ? { data: transformed, metadata: {} } : transformed; - } - - const transformed = {}; - for (const [key, value] of Object.entries(obj)) { - transformed[key] = transformBrunoTypes(value, false); - } - return returnMetadata ? { data: transformed, metadata: {} } : transformed; - }; - const formatMessage = (msg, originalArgs) => { if (originalArgs && originalArgs.length > 0) { return originalArgs.map((arg, index) => { if (typeof arg === 'object' && arg !== null) { - const { data: transformedArg, metadata } = transformBrunoTypes(arg, true); + const metadata = getBrunoTypeMetadata(arg); + const transformedArg = transformBrunoTypes(arg); // Determine the name to display based on the type let displayName = false; let shouldCollapse = 1; // Default: collapse at depth 1 for regular objects - if (metadata.type === 'Map') { - displayName = 'Map'; - shouldCollapse = true; // Fully collapse Maps by default - } else if (metadata.type === 'Set') { - displayName = 'Set'; - shouldCollapse = true; // Fully collapse Sets by default + if (metadata.type === 'Map' || metadata.type === 'Set') { + displayName = metadata.type; + shouldCollapse = true; // Fully collapse Maps/Sets by default } return ( diff --git a/packages/bruno-js/src/sandbox/node-vm/console.js b/packages/bruno-js/src/sandbox/node-vm/console.js new file mode 100644 index 000000000..9cd243d5f --- /dev/null +++ b/packages/bruno-js/src/sandbox/node-vm/console.js @@ -0,0 +1,102 @@ +/** + * Gets the type tag of a value using Object.prototype.toString + * This works across VM context boundaries unlike instanceof + * @param {*} value - The value to check + * @returns {string} The type tag (e.g., 'Set', 'Map', 'Array', 'Object') + */ +function getTypeTag(value) { + return Object.prototype.toString.call(value).slice(8, -1); +} + +/** + * Transforms a value, converting Set and Map to a special format for display + * Uses Object.prototype.toString for cross-context type detection + * @param {*} value - The value to transform + * @param {WeakSet} seen - Set of already visited objects for circular ref detection + * @returns {*} Transformed value with Set/Map converted to __brunoType format + */ +function transformValue(value, seen = new WeakSet()) { + // Return primitives as-is + if (value === null || value === undefined || typeof value !== 'object' && typeof value !== 'function') { + return value; + } + + // Circular reference check for objects + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + + const typeTag = getTypeTag(value); + + if (typeTag === 'Set') { + return { + __brunoType: 'Set', + __brunoValue: Array.from(value).map((item) => transformValue(item, seen)) + }; + } + + if (typeTag === 'Map') { + return { + __brunoType: 'Map', + __brunoValue: Array.from(value.entries()).map(([k, v]) => [ + transformValue(k, seen), + transformValue(v, seen) + ]) + }; + } + + if (typeTag === 'Array') { + return value.map((item) => transformValue(item, seen)); + } + + if (typeTag === 'Object') { + const transformed = {}; + for (const [key, val] of Object.entries(value)) { + transformed[key] = transformValue(val, seen); + } + return transformed; + } + + // Handle functions - show clean wrapper + if (typeTag === 'Function' || typeof value === 'function') { + const name = value.name || 'anonymous'; + return `function ${name}() {\n [native code]\n}`; + } + + // Handle other built-in types (Date, RegExp, Error, etc.) - convert to string representation + try { + return value?.toString?.() ?? String(value); + } catch { + return `[${typeTag}]`; + } +} + +/** + * Wraps a console object to add Set/Map support for logging + * @param {Object} originalConsole - The original console object + * @returns {Object} Wrapped console with Set/Map transformation + */ +function wrapConsoleWithSerializers(originalConsole) { + if (!originalConsole) return originalConsole; + + const methodsToWrap = ['log', 'debug', 'info', 'warn', 'error']; + const wrappedConsole = { ...originalConsole }; + + for (const method of methodsToWrap) { + if (typeof originalConsole[method] === 'function') { + wrappedConsole[method] = (...args) => { + const transformedArgs = args.map((arg) => transformValue(arg)); + originalConsole[method](...transformedArgs); + }; + } + } + + return wrappedConsole; +} + +module.exports = { + wrapConsoleWithSerializers +}; diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js index 1646623e3..ac7a3c095 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -4,6 +4,7 @@ const path = require('node:path'); const { get } = require('lodash'); const lodash = require('lodash'); const { mixinTypedArrays } = require('../mixins/typed-arrays'); +const { wrapConsoleWithSerializers } = require('./console'); class ScriptError extends Error { constructor(error, script) { @@ -47,8 +48,8 @@ async function runScriptInNodeVm({ // Create script context with all necessary variables const scriptContext = { - // Bruno context - console: context.console, + // Bruno context (wrap console with Set/Map support) + console: wrapConsoleWithSerializers(context.console), req: context.req, res: context.res, bru: context.bru, diff --git a/packages/bruno-js/src/sandbox/quickjs/index.js b/packages/bruno-js/src/sandbox/quickjs/index.js index 35f58aefc..c2f8ba079 100644 --- a/packages/bruno-js/src/sandbox/quickjs/index.js +++ b/packages/bruno-js/src/sandbox/quickjs/index.js @@ -164,54 +164,6 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external fn.apply(); } - // Override console.log to handle Sets and Maps properly - const originalConsoleLog = console.log; - console.log = function(...args) { - const processedArgs = args.map(arg => { - if (arg instanceof Set) { - return { - __brunoType: 'Set', - __brunoValue: Array.from(arg), - size: arg.size - }; - } - if (arg instanceof Map) { - return { - __brunoType: 'Map', - __brunoValue: Array.from(arg.entries()), - size: arg.size - }; - } - return arg; - }); - return originalConsoleLog.apply(this, processedArgs); - }; - - // Also override other console methods - ['debug', 'info', 'warn', 'error'].forEach(method => { - const originalMethod = console[method]; - console[method] = function(...args) { - const processedArgs = args.map(arg => { - if (arg instanceof Set) { - return { - __brunoType: 'Set', - __brunoValue: Array.from(arg), - size: arg.size - }; - } - if (arg instanceof Map) { - return { - __brunoType: 'Map', - __brunoValue: Array.from(arg.entries()), - size: arg.size - }; - } - return arg; - }); - return originalMethod.apply(this, processedArgs); - }; - }); - await bru.sleep(0); try { ${externalScript} diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/console.js b/packages/bruno-js/src/sandbox/quickjs/shims/console.js index 8a7101347..1430f4722 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/console.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/console.js @@ -2,150 +2,121 @@ const addConsoleShimToContext = (vm, console) => { if (!console) return; // Helper function to convert QuickJS values to native values with Set/Map support - const dumpWithSetMapSupport = arg => { + const dumpWithSerializers = (arg) => { + // Track all handles for centralized disposal + let nameProp, constructorProp, constructorNameProp, toStringFn, toStringResult; + let arrayFn, fromFn, arrayResult; + try { - // First try to dump normally - const dumped = vm.dump(arg); + const argType = vm.typeof(arg); - // Check if it's a Set by trying to access Set-specific properties - if (vm.typeof(arg) === 'object' && arg !== vm.null && arg !== vm.undefined) { - // Try to get the constructor name - const constructorProp = vm.getProp(arg, 'constructor'); - if (constructorProp) { - const constructorNameProp = vm.getProp(constructorProp, 'name'); - if (constructorNameProp) { - const constructorName = vm.dump(constructorNameProp); - constructorNameProp.dispose(); - - if (constructorName === 'Set') { - // It's a Set, convert to our special format - const sizeProp = vm.getProp(arg, 'size'); - const size = sizeProp ? vm.dump(sizeProp) : 0; - sizeProp?.dispose(); - - // Get values by calling values() method - const valuesFn = vm.getProp(arg, 'values'); - if (valuesFn) { - const valuesIterator = vm.callFunction(valuesFn, arg); - const values = []; - - // Try to extract values (this is a simplified approach) - try { - // For now, we'll use a different approach - convert via Array.from - const arrayFromFn = vm.getProp(vm.global, 'Array'); - if (arrayFromFn) { - const fromFn = vm.getProp(arrayFromFn, 'from'); - if (fromFn) { - const arrayResult = vm.callFunction(fromFn, arrayFromFn, arg); - const arrayValues = vm.dump(arrayResult); - arrayResult.dispose(); - fromFn.dispose(); - - constructorProp.dispose(); - valuesFn.dispose(); - valuesIterator?.dispose(); - arrayFromFn.dispose(); - - return { - __brunoType: 'Set', - __brunoValue: arrayValues, - size: size, - }; - } - fromFn?.dispose(); - } - arrayFromFn?.dispose(); - } catch (e) { - // Fallback to empty array - } - - valuesFn.dispose(); - valuesIterator?.dispose(); - - return { - __brunoType: 'Set', - __brunoValue: values, - size: size, - }; - } - } else if (constructorName === 'Map') { - // It's a Map, convert to our special format - const sizeProp = vm.getProp(arg, 'size'); - const size = sizeProp ? vm.dump(sizeProp) : 0; - sizeProp?.dispose(); - - // Try to convert via Array.from - try { - const arrayFromFn = vm.getProp(vm.global, 'Array'); - if (arrayFromFn) { - const fromFn = vm.getProp(arrayFromFn, 'from'); - if (fromFn) { - const arrayResult = vm.callFunction(fromFn, arrayFromFn, arg); - const arrayValues = vm.dump(arrayResult); - arrayResult.dispose(); - fromFn.dispose(); - arrayFromFn.dispose(); - - constructorProp.dispose(); - - return { - __brunoType: 'Map', - __brunoValue: arrayValues, - size: size, - }; - } - fromFn?.dispose(); - } - arrayFromFn?.dispose(); - } catch (e) { - // Fallback - } - - constructorProp.dispose(); - - return { - __brunoType: 'Map', - __brunoValue: [], - size: size, - }; - } - } - constructorNameProp?.dispose(); - } - constructorProp?.dispose(); + // Early return for primitives (string, number, boolean, undefined, null) + if (arg == null || arg === vm.null || arg === vm.undefined) { + return vm.dump(arg); } - return dumped; + if (argType !== 'object' && argType !== 'function') { + return vm.dump(arg); + } + + // Handle functions - show clean wrapper + if (argType === 'function') { + nameProp = vm.getProp(arg, 'name'); + const name = nameProp ? vm.dump(nameProp) || 'anonymous' : 'anonymous'; + return `function ${name}() {\n [native code]\n}`; + } + + // Try to get the constructor name to detect Set/Map + constructorProp = vm.getProp(arg, 'constructor'); + if (!constructorProp) { + return vm.dump(arg); + } + + let constructorName = null; + constructorNameProp = vm.getProp(constructorProp, 'name'); + if (constructorNameProp) { + constructorName = vm.dump(constructorNameProp); + } + + // Handle Date, RegExp, Error - call toString() + if (constructorName === 'Date' || constructorName === 'RegExp' || constructorName?.endsWith?.('Error')) { + toStringFn = vm.getProp(arg, 'toString'); + if (toStringFn) { + toStringResult = vm.callFunction(toStringFn, arg); + if (toStringResult.error) { + return vm.dump(arg); + } + return vm.dump(toStringResult.value); + } + } + + // If not a Set or Map, use standard dump + if (constructorName !== 'Set' && constructorName !== 'Map') { + return vm.dump(arg); + } + + // Convert Set or Map to array via Array.from + arrayFn = vm.getProp(vm.global, 'Array'); + if (!arrayFn) { + return vm.dump(arg); + } + + fromFn = vm.getProp(arrayFn, 'from'); + if (!fromFn) { + return vm.dump(arg); + } + + arrayResult = vm.callFunction(fromFn, arrayFn, arg); + if (arrayResult.error) { + return vm.dump(arg); + } + + return { + __brunoType: constructorName, + __brunoValue: vm.dump(arrayResult.value) + }; } catch (e) { // Fallback to normal dump return vm.dump(arg); + } finally { + // Centralized handle disposal - dispose all handles regardless of success or error + nameProp?.dispose(); + constructorProp?.dispose(); + constructorNameProp?.dispose(); + toStringFn?.dispose(); + toStringResult?.value?.dispose(); + toStringResult?.error?.dispose(); + arrayFn?.dispose(); + fromFn?.dispose(); + arrayResult?.value?.dispose(); + arrayResult?.error?.dispose(); } }; const consoleHandle = vm.newObject(); const logHandle = vm.newFunction('log', (...args) => { - const nativeArgs = args.map(dumpWithSetMapSupport); + const nativeArgs = args.map(dumpWithSerializers); console?.log?.(...nativeArgs); }); const debugHandle = vm.newFunction('debug', (...args) => { - const nativeArgs = args.map(dumpWithSetMapSupport); + const nativeArgs = args.map(dumpWithSerializers); console?.debug?.(...nativeArgs); }); const infoHandle = vm.newFunction('info', (...args) => { - const nativeArgs = args.map(dumpWithSetMapSupport); + const nativeArgs = args.map(dumpWithSerializers); console?.info?.(...nativeArgs); }); const warnHandle = vm.newFunction('warn', (...args) => { - const nativeArgs = args.map(dumpWithSetMapSupport); + const nativeArgs = args.map(dumpWithSerializers); console?.warn?.(...nativeArgs); }); const errorHandle = vm.newFunction('error', (...args) => { - const nativeArgs = args.map(dumpWithSetMapSupport); + const nativeArgs = args.map(dumpWithSerializers); console?.error?.(...nativeArgs); });