From bc09a26fa6a6f2d6f837b6909a401f5b456ea2b6 Mon Sep 17 00:00:00 2001 From: Bijin A B Date: Thu, 12 Feb 2026 22:18:32 +0530 Subject: [PATCH] feat(hooks): refactor to initialise hook-manager only once per req execution --- packages/bruno-cli/src/commands/run.js | 76 ++--- .../src/runner/run-single-request.js | 85 +++--- packages/bruno-cli/src/utils/collection.js | 14 +- .../bruno-electron/src/ipc/network/index.js | 273 ++++++++++-------- .../bruno-electron/src/utils/collection.js | 14 +- packages/bruno-js/src/bru.js | 12 - packages/bruno-js/src/hook-manager.js | 59 ++-- packages/bruno-js/src/index.js | 2 + .../bruno-js/src/runtime/hooks-runtime.js | 79 ++--- .../bruno-js/src/sandbox/quickjs/shims/bru.js | 2 - packages/bruno-js/tests/hook-manager.spec.js | 16 - 11 files changed, 300 insertions(+), 332 deletions(-) diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 5ebf3487c..d8952992b 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -15,10 +15,11 @@ const { rpad } = require('../utils/common'); const { getOptions } = require('../utils/bru'); const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore'); const constants = require('../constants'); -const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG, HOOK_EVENTS } = require('../utils/collection'); +const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection'); const { hasExecutableTestInScript } = require('../utils/request'); const { createSkippedFileResults } = require('../utils/run'); -const { HooksRuntime } = require('@usebruno/js'); +const { HooksRuntime, HookManager } = require('@usebruno/js'); +const HOOK_EVENTS = HookManager.EVENTS; const decomment = require('decomment'); const command = 'run [paths...]'; const desc = 'Run one or more requests/folders'; @@ -622,7 +623,7 @@ const handler = async function (argv) { console[type](...args); }; - // Define runSingleRequestByPathname before executeCollectionHooks so it's available at all hook levels + // Define runSingleRequestByPathname before hooks initialization so it's available at all hook levels const runSingleRequestByPathname = async (relativeItemPathname) => { const ext = FORMAT_CONFIG[collection.format].ext; return new Promise(async (resolve, reject) => { @@ -650,44 +651,41 @@ const handler = async function (argv) { }); }; - // Helper function to execute collection-level hooks at runtime - const executeCollectionHooks = async (hookEvent, eventData) => { + // Initialize collection-level hooks once (evaluated once, reused for before/after events) + let collectionHooksCtx = null; + { collectionRoot = collection?.draft?.root || collection?.root || {}; const collectionHooks = get(collectionRoot, 'request.script.hooks', ''); - if (!collectionHooks || !collectionHooks.trim()) { - return; - } - - try { - const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); - const result = await hooksRuntime.runHooks({ - hooksFile: decomment(collectionHooks), - request: {}, // Placeholder request for collection-level hooks - envVariables: envVars, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname: runSingleRequestByPathname, - collectionName - }); - - if (result?.hookManager) { - await result.hookManager.call(hookEvent, eventData); - // Dispose HookManager to free VM resources - if (result.hookManager && typeof result.hookManager.dispose === 'function') { - result.hookManager.dispose(); - } + if (collectionHooks && collectionHooks.trim()) { + try { + const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); + collectionHooksCtx = await hooksRuntime.runHooks({ + hooksFile: decomment(collectionHooks), + request: {}, // Placeholder request for collection-level hooks + envVariables: envVars, + runtimeVariables, + collectionPath, + onConsoleLog, + processEnvVars, + scriptingConfig, + runRequestByItemPathname: runSingleRequestByPathname, + collectionName + }); + } catch (error) { + console.error('Error initializing collection-level hooks:', error); } - } catch (error) { - console.error(`Error executing collection-level hooks for ${hookEvent}:`, error); } - }; + } // Call onBeforeCollectionRun hook before starting to run requests - await executeCollectionHooks(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection }); + if (collectionHooksCtx?.hookManager) { + try { + await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection }); + } catch (error) { + console.error('Error executing beforeCollectionRun hook:', error); + } + } let currentRequestIndex = 0; let nJumps = 0; // count the number of jumps to avoid infinite loops @@ -802,7 +800,15 @@ const handler = async function (argv) { results.push(...skippedFileResults); // Call onAfterCollectionRun hook after all requests are done - await executeCollectionHooks(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection }); + if (collectionHooksCtx?.hookManager) { + try { + await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection }); + } catch (error) { + console.error('Error executing afterCollectionRun hook:', error); + } + collectionHooksCtx.hookManager.dispose(); + collectionHooksCtx = null; + } const summary = printRunSummary(results); const runCompletionTime = new Date().toISOString(); diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 5d0cb8bb8..50e34da18 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -6,10 +6,11 @@ const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lod const prepareRequest = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); const { interpolateString, interpolateObject } = require('./interpolate-string'); -const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, HooksRuntime } = require('@usebruno/js'); +const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, HooksRuntime, HookManager, BrunoResponse } = require('@usebruno/js'); +const HOOK_EVENTS = HookManager.EVENTS; const { stripExtension } = require('../utils/filesystem'); const { getOptions } = require('../utils/bru'); -const { getTreePathFromCollectionToItem, HOOK_EVENTS } = require('../utils/collection'); +const { getTreePathFromCollectionToItem } = require('../utils/collection'); const https = require('https'); const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); @@ -215,53 +216,64 @@ const runSingleRequest = async function ( const requestTreePath = getTreePathFromCollectionToItem(collection, item); const collectionName = collection?.brunoConfig?.name; - // Helper function to execute merged hooks (hooks are merged in prepareRequest via mergeScripts) - const executeHooks = async (hookEvent, eventData) => { - const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); - const result = await hooksRuntime.runHooks({ - hooksFile: request.script?.hooks, - request, - response: eventData.response, - envVariables, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname: runSingleRequestByPathname, - collectionName - }); + // Initialize hooks once for this request lifecycle (reused for beforeRequest + afterResponse) + let hooksCtx = null; + const hooksFile = request.script?.hooks; + if (hooksFile && hooksFile.trim()) { + try { + const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); + hooksCtx = await hooksRuntime.runHooks({ + hooksFile, + request, + envVariables, + runtimeVariables, + collectionPath, + onConsoleLog, + processEnvVars, + scriptingConfig, + runRequestByItemPathname: runSingleRequestByPathname, + collectionName + }); + } catch (error) { + console.error('Error initializing hooks:', error); + } + } - if (result?.hookManager) { - // Enrich eventData with runtime-created req/res wrappers + /** + * Trigger a specific hook event using the initialized hooks context. + * Re-reads runner control signals from bru instance after handlers execute. + */ + const triggerHookEvent = async (hookEvent, eventData) => { + if (!hooksCtx?.hookManager) return null; + + try { const enrichedEventData = { ...eventData, - req: result.req || eventData.req, - res: result.res || eventData.res + req: hooksCtx.req || eventData.req, + res: eventData.response ? new BrunoResponse(eventData.response) : (hooksCtx.res || eventData.res) }; - await result.hookManager.call(hookEvent, enrichedEventData); + await hooksCtx.hookManager.call(hookEvent, enrichedEventData); // Re-read runner control signals from bru instance AFTER handlers have executed - // Handlers may have called bru.runner.setNextRequest(), skipRequest(), or stopExecution() - // which update the bru instance but were not captured in the initial result - const bru = result.__bru; + const bru = hooksCtx.__bru; if (bru) { - result.nextRequestName = bru.nextRequest; - result.skipRequest = bru.skipRequest; - result.stopExecution = bru.stopExecution; + hooksCtx.nextRequestName = bru.nextRequest; + hooksCtx.skipRequest = bru.skipRequest; + hooksCtx.stopExecution = bru.stopExecution; } - result.hookManager.dispose(); + return hooksCtx; + } catch (error) { + console.error(`Error executing hooks for ${hookEvent}:`, error); + return null; } - - return result; }; // Call beforeRequest hooks before running pre-request scripts // Hooks are called in registration order: collection -> folder(s) -> request const beforeRequestEventData = { request, collection }; - const beforeRequestHooksResult = await executeHooks( + const beforeRequestHooksResult = await triggerHookEvent( HOOK_EVENTS.HTTP_BEFORE_REQUEST, beforeRequestEventData ); @@ -738,11 +750,16 @@ const runSingleRequest = async function ( // Hooks are called in registration order: collection -> folder(s) -> request const afterResponseEventData = { request, response, collection }; - const afterResponseHooksResult = await executeHooks( + const afterResponseHooksResult = await triggerHookEvent( HOOK_EVENTS.HTTP_AFTER_RESPONSE, afterResponseEventData ); + // Dispose hooks context — done with all events for this request + if (hooksCtx?.hookManager) { + hooksCtx.hookManager.dispose(); + } + // Check runner control from hooks applyRunnerControlFromResult(afterResponseHooksResult, runnerState); nextRequestName = runnerState.nextRequestName; diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 37d2a34fc..f2f2c6ba1 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -391,17 +391,6 @@ const mergeAuth = (collection, request, requestTreePath) => { } }; -/** - * Hook event names used throughout the application. - * This object is frozen to prevent accidental modifications and improve maintainability. - */ -const HOOK_EVENTS = Object.freeze({ - HTTP_BEFORE_REQUEST: 'http:beforeRequest', - HTTP_AFTER_RESPONSE: 'http:afterResponse', - RUNNER_BEFORE_COLLECTION_RUN: 'runner:beforeCollectionRun', - RUNNER_AFTER_COLLECTION_RUN: 'runner:afterCollectionRun' -}); - const getAllRequestsInFolder = (folderItems = [], recursive = true) => { let requests = []; @@ -631,6 +620,5 @@ module.exports = { mergeAuth, getAllRequestsInFolder, getAllRequestsAtFolderRoot, - getCallStack, - HOOK_EVENTS + getCallStack }; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 5cba9f95e..9b363fc2e 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -8,7 +8,8 @@ const mime = require('mime-types'); const { ipcMain } = require('electron'); const { each, get, extend, cloneDeep, merge } = require('lodash'); const { NtlmClient } = require('axios-ntlm'); -const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime, HooksRuntime } = require('@usebruno/js'); +const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime, HooksRuntime, HookManager, BrunoResponse } = require('@usebruno/js'); +const HOOK_EVENTS = HookManager.EVENTS; const { encodeUrl } = require('@usebruno/common').utils; const { extractPromptVariables } = require('@usebruno/common').utils; const { interpolateString } = require('./interpolate-string'); @@ -24,7 +25,7 @@ const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseData const { chooseFileToSave, writeFile, getCollectionFormat, hasRequestExtension } = require('../../utils/filesystem'); const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies'); const { createFormData } = require('../../utils/form-data'); -const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence, HOOK_EVENTS } = require('../../utils/collection'); +const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials } = require('../../utils/oauth2'); const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); @@ -490,19 +491,21 @@ const registerNetworkIpc = (mainWindow) => { }; /** - * Execute merged hooks for a specific event - * @param {string} hookEvent - Hook event to trigger - * @param {object} eventData - Data to pass to hook handlers + * Initialize hooks by evaluating the hooks file once. + * Returns context for subsequent event triggering via triggerHookEvent(). + * Caller must dispose hooksCtx.hookManager when done with all events. * @param {object} options - Configuration options - * @returns {Promise} Execution result or null if error + * @returns {Promise} Hooks context or null if no hooks/error */ - const executeHooks = async (hookEvent, eventData, options) => { + const initHooks = async (options) => { + const hooksFile = options.request?.script?.hooks; + if (!hooksFile || !hooksFile.trim()) return null; + try { const hooksRuntime = new HooksRuntime({ runtime: options.scriptingConfig?.runtime }); const result = await hooksRuntime.runHooks({ - hooksFile: options.request?.script?.hooks, + hooksFile, request: options.request || {}, - response: eventData.response, envVariables: options.envVars, runtimeVariables: options.runtimeVariables, collectionPath: options.collectionPath, @@ -513,29 +516,6 @@ const registerNetworkIpc = (mainWindow) => { collectionName: options.collectionName }); - if (result?.hookManager) { - // Enrich eventData with runtime-created req/res wrappers - const enrichedEventData = { - ...eventData, - req: result.req || eventData.req, - res: result.res || eventData.res - }; - await result.hookManager.call(hookEvent, enrichedEventData); - - // Re-read runner control signals from bru instance AFTER handlers have executed - // Handlers may have called bru.runner.setNextRequest(), skipRequest(), or stopExecution() - // which update the bru instance but were not captured in the initial result - const bru = result.__bru; - if (bru) { - result.nextRequestName = bru.nextRequest; - result.skipRequest = bru.skipRequest; - result.stopExecution = bru.stopExecution; - } - - result.hookManager.dispose(); - } - - // Send UI updates if we have a result if (result) { await sendScriptEnvironmentUpdates({ scriptResult: result, @@ -545,23 +525,55 @@ const registerNetworkIpc = (mainWindow) => { updateCookies: true }); } - return result; + } catch (error) { + console.error('Error initializing hooks:', error); + options.onConsoleLog?.('error', [`Error initializing hooks: ${error.message}`]); + return null; + } + }; + + /** + * Trigger a specific hook event using an already-initialized hooks context. + * Re-reads runner control signals from bru instance after handlers execute. + * @param {object} hooksCtx - Context from initHooks() + * @param {string} hookEvent - Hook event to trigger + * @param {object} eventData - Data to pass to hook handlers + * @param {object} options - Configuration options + * @returns {Promise} Updated hooks context or null if error + */ + const triggerHookEvent = async (hooksCtx, hookEvent, eventData, options) => { + if (!hooksCtx?.hookManager) return null; + + try { + const enrichedEventData = { + ...eventData, + req: hooksCtx.req || eventData.req, + res: eventData.response ? new BrunoResponse(eventData.response) : (hooksCtx.res || eventData.res) + }; + await hooksCtx.hookManager.call(hookEvent, enrichedEventData); + + // Re-read runner control signals from bru instance AFTER handlers have executed + // Handlers may have called bru.runner.setNextRequest(), skipRequest(), or stopExecution() + const bru = hooksCtx.__bru; + if (bru) { + hooksCtx.nextRequestName = bru.nextRequest; + hooksCtx.skipRequest = bru.skipRequest; + hooksCtx.stopExecution = bru.stopExecution; + } + + await sendScriptEnvironmentUpdates({ + scriptResult: hooksCtx, + collection: options.collection, + collectionUid: options.collectionUid, + requestUid: options.requestUid, + updateCookies: true + }); + + return hooksCtx; } catch (error) { console.error(`Error executing hooks for ${hookEvent}:`, error); options.onConsoleLog?.('error', [`Error executing hooks for ${hookEvent}: ${error.message}`]); - if (!options.runInBackground && options.notifyScriptExecution && typeof options.notifyScriptExecution === 'function') { - options.notifyScriptExecution({ - channel: 'main:run-request-event', - basePayload: { - requestUid: options.requestUid, - collectionUid: options.collectionUid, - itemUid: options.itemUid - }, - scriptType: 'hooks', - error - }); - } return null; } }; @@ -766,10 +778,9 @@ const registerNetworkIpc = (mainWindow) => { request.signal = abortController.signal; saveCancelToken(cancelTokenUid, abortController); - // Call beforeRequest hooks before running pre-request scripts + // Initialize hooks once for this request lifecycle // Hooks are merged in prepareRequest via mergeScripts // Hooks are called in registration order: collection -> folder(s) -> request - const beforeRequestEventData = { request, collection, collectionUid }; const hookOptions = { request, envVars, @@ -788,12 +799,11 @@ const registerNetworkIpc = (mainWindow) => { notifyScriptExecution }; - // Call beforeRequest hooks using merged hooks - await executeHooks( - HOOK_EVENTS.HTTP_BEFORE_REQUEST, - beforeRequestEventData, - hookOptions - ); + const hooksCtx = await initHooks(hookOptions); + + // Call beforeRequest hooks using initialized hooks context + const beforeRequestEventData = { request, collection, collectionUid }; + await triggerHookEvent(hooksCtx, HOOK_EVENTS.HTTP_BEFORE_REQUEST, beforeRequestEventData, hookOptions); let preRequestScriptResult = null; let preRequestError = null; @@ -963,12 +973,13 @@ const registerNetworkIpc = (mainWindow) => { // Hooks are called in registration order: collection -> folder(s) -> request const afterResponseEventData = { request, response, collection, collectionUid }; - // Call afterResponse hooks using merged hooks - await executeHooks( - HOOK_EVENTS.HTTP_AFTER_RESPONSE, - afterResponseEventData, - hookOptions - ); + // Call afterResponse hooks using already-initialized hooks context + await triggerHookEvent(hooksCtx, HOOK_EVENTS.HTTP_AFTER_RESPONSE, afterResponseEventData, hookOptions); + + // Dispose hooks context — done with all events for this request + if (hooksCtx?.hookManager) { + hooksCtx.hookManager.dispose(); + } const runPostScripts = async () => { let postResponseScriptResult = null; @@ -1286,61 +1297,61 @@ const registerNetworkIpc = (mainWindow) => { const isCollectionRun = folder?.uid === collection?.uid; - // Helper function to execute collection-level hooks at runtime - const executeCollectionHooks = async (hookEvent, eventData) => { + // Initialize collection-level hooks once (evaluated once, reused for before/after events) + let collectionHooksCtx = null; + if (isCollectionRun) { const collectionRoot = collection?.draft?.root || collection?.root || {}; const collectionHooks = get(collectionRoot, 'request.script.hooks', ''); - if (!collectionHooks || !collectionHooks.trim()) { - return; - } - - try { - const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); - const result = await hooksRuntime.runHooks({ - hooksFile: decomment(collectionHooks), - request: {}, // Placeholder request for collection-level hooks - envVariables: envVars, - runtimeVariables, - collectionPath, - onConsoleLog: (type, args) => { - console[type](...args); - mainWindow.webContents.send('main:console-log', { - type, - args - }); - }, - processEnvVars, - scriptingConfig, - runRequestByItemPathname, - collectionName: collection?.name - }); - - if (result?.hookManager) { - await result.hookManager.call(hookEvent, eventData); - // Dispose HookManager to free VM resources - if (result.hookManager && typeof result.hookManager.dispose === 'function') { - result.hookManager.dispose(); - } - } - - // Send UI updates after collection-level hooks execution - if (result) { - await sendScriptEnvironmentUpdates({ - scriptResult: result, - collection, - collectionUid, - updateCookies: true + if (collectionHooks && collectionHooks.trim()) { + try { + const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); + collectionHooksCtx = await hooksRuntime.runHooks({ + hooksFile: decomment(collectionHooks), + request: {}, // Placeholder request for collection-level hooks + envVariables: envVars, + runtimeVariables, + collectionPath, + onConsoleLog: (type, args) => { + console[type](...args); + mainWindow.webContents.send('main:console-log', { + type, + args + }); + }, + processEnvVars, + scriptingConfig, + runRequestByItemPathname, + collectionName: collection?.name }); + + if (collectionHooksCtx) { + await sendScriptEnvironmentUpdates({ + scriptResult: collectionHooksCtx, + collection, + collectionUid, + updateCookies: true + }); + } + } catch (error) { + console.error('Error initializing collection-level hooks:', error); } - } catch (error) { - console.error(`Error executing collection-level hooks for ${hookEvent}:`, error); } - }; + } // Call onBeforeCollectionRun hook before starting to run requests - if (isCollectionRun) { - await executeCollectionHooks(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection, collectionUid }); + if (collectionHooksCtx?.hookManager) { + try { + await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection, collectionUid }); + await sendScriptEnvironmentUpdates({ + scriptResult: collectionHooksCtx, + collection, + collectionUid, + updateCookies: true + }); + } catch (error) { + console.error('Error executing beforeCollectionRun hook:', error); + } } try { @@ -1493,11 +1504,15 @@ const registerNetworkIpc = (mainWindow) => { } }; + // Initialize hooks once per request iteration (reused for beforeRequest + afterResponse) + const hooksCtx = await initHooks(hookOptions); + try { - // Call beforeRequest hooks using merged hooks + // Call beforeRequest hooks using initialized hooks context const beforeRequestEventData = { request, collection, collectionUid }; - const beforeRequestHooksResult = await executeHooks( + const beforeRequestHooksResult = await triggerHookEvent( + hooksCtx, HOOK_EVENTS.HTTP_BEFORE_REQUEST, beforeRequestEventData, hookOptions @@ -1748,15 +1763,21 @@ const registerNetworkIpc = (mainWindow) => { } } - // Call afterResponse hooks using merged hooks + // Call afterResponse hooks using already-initialized hooks context const afterResponseEventData = { request, response, collection, collectionUid }; - const afterResponseHooksResult = await executeHooks( + const afterResponseHooksResult = await triggerHookEvent( + hooksCtx, HOOK_EVENTS.HTTP_AFTER_RESPONSE, afterResponseEventData, hookOptions ); + // Dispose hooks context — done with all events for this request + if (hooksCtx?.hookManager) { + hooksCtx.hookManager.dispose(); + } + // Check runner control from hooks if (afterResponseHooksResult?.nextRequestName !== undefined) { nextRequestName = afterResponseHooksResult.nextRequestName; @@ -1939,8 +1960,20 @@ const registerNetworkIpc = (mainWindow) => { } // Call onAfterCollectionRun hook after all requests are done - if (isCollectionRun) { - await executeCollectionHooks(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid }); + if (collectionHooksCtx?.hookManager) { + try { + await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid }); + await sendScriptEnvironmentUpdates({ + scriptResult: collectionHooksCtx, + collection, + collectionUid, + updateCookies: true + }); + } catch (hookError) { + console.error('Error executing afterCollectionRun hook:', hookError); + } + collectionHooksCtx.hookManager.dispose(); + collectionHooksCtx = null; } deleteCancelToken(cancelTokenUid); @@ -1954,8 +1987,20 @@ const registerNetworkIpc = (mainWindow) => { console.log('error', error); // Call onAfterCollectionRun hook even on error - if (isCollectionRun) { - await executeCollectionHooks(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid }); + if (collectionHooksCtx?.hookManager) { + try { + await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid }); + await sendScriptEnvironmentUpdates({ + scriptResult: collectionHooksCtx, + collection, + collectionUid, + updateCookies: true + }); + } catch (hookError) { + console.error('Error executing afterCollectionRun hook:', hookError); + } + collectionHooksCtx.hookManager.dispose(); + collectionHooksCtx = null; } deleteCancelToken(cancelTokenUid); diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 788d7b1c5..8a3d718db 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -249,17 +249,6 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { request.script.hooks = compact(hooksScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); }; -/** - * Hook event names used throughout the application. - * This object is frozen to prevent accidental modifications and improve maintainability. - */ -const HOOK_EVENTS = Object.freeze({ - HTTP_BEFORE_REQUEST: 'http:beforeRequest', - HTTP_AFTER_RESPONSE: 'http:afterResponse', - RUNNER_BEFORE_COLLECTION_RUN: 'runner:beforeCollectionRun', - RUNNER_AFTER_COLLECTION_RUN: 'runner:afterCollectionRun' -}); - const flattenItems = (items = []) => { const flattenedItems = []; @@ -785,6 +774,5 @@ module.exports = { getEnvVars, getFormattedCollectionOauth2Credentials, sortByNameThenSequence, - resolveInheritedSettings, - HOOK_EVENTS + resolveInheritedSettings }; diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js index fef697295..ef98cde8c 100644 --- a/packages/bruno-js/src/bru.js +++ b/packages/bruno-js/src/bru.js @@ -312,29 +312,17 @@ class Bru { this.hooks = { runner: { onBeforeCollectionRun: (handler) => { - if (!this.#hookManager) { - throw new Error('HookManager is not available'); - } return this.#hookManager.on('runner:beforeCollectionRun', handler); }, onAfterCollectionRun: (handler) => { - if (!this.#hookManager) { - throw new Error('HookManager is not available'); - } return this.#hookManager.on('runner:afterCollectionRun', handler); } }, http: { onBeforeRequest: (handler) => { - if (!this.#hookManager) { - throw new Error('HookManager is not available'); - } return this.#hookManager.on('http:beforeRequest', handler); }, onAfterResponse: (handler) => { - if (!this.#hookManager) { - throw new Error('HookManager is not available'); - } return this.#hookManager.on('http:afterResponse', handler); } } diff --git a/packages/bruno-js/src/hook-manager.js b/packages/bruno-js/src/hook-manager.js index bd794f879..c362034c9 100644 --- a/packages/bruno-js/src/hook-manager.js +++ b/packages/bruno-js/src/hook-manager.js @@ -1,7 +1,7 @@ /** * HookManager provides a simple event system for registering and calling hooks (event listeners). * - * Hooks can be registered for specific string patterns or arrays of patterns. The special pattern '*' acts as a wildcard. + * Hooks can be registered for specific string patterns or arrays of patterns. * * Usage examples: * @@ -36,6 +36,17 @@ class HookManager { DISPOSED: 'disposed' }); + /** + * Standard hook event names used throughout the application. + * @readonly + */ + static EVENTS = Object.freeze({ + HTTP_BEFORE_REQUEST: 'http:beforeRequest', + HTTP_AFTER_RESPONSE: 'http:afterResponse', + RUNNER_BEFORE_COLLECTION_RUN: 'runner:beforeCollectionRun', + RUNNER_AFTER_COLLECTION_RUN: 'runner:afterCollectionRun' + }); + constructor() { this.listeners = {}; this._state = HookManager.State.ACTIVE; @@ -87,8 +98,6 @@ class HookManager { /** * Call all registered handlers for the given pattern(s) * Supports both sync and async handlers - all handlers are awaited - * Wildcard handlers ('*') are called for every pattern, in addition to specific pattern handlers - * * Error Isolation: Errors in one handler don't stop other handlers from running. * Errors are logged to console but don't affect execution flow. * @@ -108,31 +117,11 @@ class HookManager { } const patternList = [].concat(pattern).map((d) => String(d).trim()); - const hasWildcard = patternList.includes('*'); - /** - * Execute a single handler with error handling - * @param {Function} handler - Handler to execute - * @param {string} event - Event name - */ - const executeHandler = async (handler, event) => { - await callHandler(handler, data, event); - }; - - if (hasWildcard) { - for (const ptn of Object.keys(this.listeners)) { - const handlers = this.listeners[ptn]; - for (const handler of handlers) { - await executeHandler(handler, ptn); - } - } - } else { - // Call handlers for each specific pattern - for (const ptn of patternList) { - if (!this.listeners[ptn]) continue; - for (const handler of this.listeners[ptn]) { - await executeHandler(handler, ptn); - } + for (const ptn of patternList) { + if (!this.listeners[ptn]) continue; + for (const handler of this.listeners[ptn]) { + await callHandler(handler, data, ptn); } } } @@ -156,12 +145,6 @@ class HookManager { } const patternList = [].concat(pattern).map((d) => String(d).trim()); - const hasWildcard = patternList.includes('*'); - - if (hasWildcard) { - (this.listeners['*'] ||= []).push(handler); - return this._createUnhook(patternList, handler); - } for (const ptn of patternList) { this.listeners[ptn] ||= []; @@ -192,12 +175,6 @@ class HookManager { patterns = patternList; } - const hasStar = patterns.includes('*'); - - if (hasStar && self.listeners['*']) { - self.listeners['*'] = self.listeners['*'].filter((d) => !Object.is(d, handler)); - } - for (const ptn of patterns) { if (!self.listeners[ptn]) continue; self.listeners[ptn] = self.listeners[ptn].filter((d) => !Object.is(d, handler)); @@ -217,9 +194,7 @@ class HookManager { const patternList = [].concat(pattern).map((d) => String(d).trim()); for (const ptn of patternList) { - if (ptn === '*') { - delete this.listeners['*']; - } else if (this.listeners[ptn]) { + if (this.listeners[ptn]) { delete this.listeners[ptn]; } } diff --git a/packages/bruno-js/src/index.js b/packages/bruno-js/src/index.js index 53d6f6782..52daffb75 100644 --- a/packages/bruno-js/src/index.js +++ b/packages/bruno-js/src/index.js @@ -4,6 +4,7 @@ const VarsRuntime = require('./runtime/vars-runtime'); const AssertRuntime = require('./runtime/assert-runtime'); const HooksRuntime = require('./runtime/hooks-runtime'); const HookManager = require('./hook-manager'); +const BrunoResponse = require('./bruno-response'); const { runScriptInNodeVm } = require('./sandbox/node-vm'); module.exports = { @@ -13,5 +14,6 @@ module.exports = { AssertRuntime, HooksRuntime, HookManager, + BrunoResponse, runScriptInNodeVm }; diff --git a/packages/bruno-js/src/runtime/hooks-runtime.js b/packages/bruno-js/src/runtime/hooks-runtime.js index 511231a66..13fb6cec6 100644 --- a/packages/bruno-js/src/runtime/hooks-runtime.js +++ b/packages/bruno-js/src/runtime/hooks-runtime.js @@ -110,56 +110,8 @@ class HooksRuntime { context.bru.runRequest = runRequestByItemPathname; } - // Lazy VM creation: If no hooks file, return early without creating a VM - if (this._isEmptyContent(hooksFile)) { - return { - hookManager: activeHookManager, - envVariables: cleanJson(envVariables), - runtimeVariables: cleanJson(runtimeVariables), - persistentEnvVariables: bru.persistentEnvVariables, - globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), - nextRequestName: bru.nextRequest, - skipRequest: bru.skipRequest, - stopExecution: bru.stopExecution, - __bru: bru, - req, - res - }; - } - - // Execute hooks script - // Note: Hooks need the VM to persist so registered handlers can be called later - if (this.runtime === 'nodevm') { - await runScriptInNodeVm({ - script: hooksFile, - context, - collectionPath, - scriptingConfig - }); - - return { - hookManager: activeHookManager, - envVariables: cleanJson(envVariables), - runtimeVariables: cleanJson(runtimeVariables), - persistentEnvVariables: bru.persistentEnvVariables, - globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), - nextRequestName: bru.nextRequest, - skipRequest: bru.skipRequest, - stopExecution: bru.stopExecution, - __bru: bru, - req, - res - }; - } - - // For QuickJS, persist the VM so hook handlers can be called later during the collection run - await executeQuickJsVmAsync({ - script: hooksFile, - context: context, - collectionPath - }); - - return { + // Build result from current state — shared across all return paths + const buildResult = () => ({ hookManager: activeHookManager, envVariables: cleanJson(envVariables), runtimeVariables: cleanJson(runtimeVariables), @@ -171,7 +123,32 @@ class HooksRuntime { __bru: bru, req, res - }; + }); + + // Lazy VM creation: If no hooks file, return early without creating a VM + if (this._isEmptyContent(hooksFile)) { + return buildResult(); + } + + // Execute hooks script + // Note: Hooks need the VM to persist so registered handlers can be called later + if (this.runtime === 'nodevm') { + await runScriptInNodeVm({ + script: hooksFile, + context, + collectionPath, + scriptingConfig + }); + } else { + // QuickJS: persist the VM so hook handlers can be called later during the collection run + await executeQuickJsVmAsync({ + script: hooksFile, + context: context, + collectionPath + }); + } + + return buildResult(); } } diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js index 29d3f4e43..3d329e6a0 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js @@ -398,8 +398,6 @@ const addBruShimToContext = (vm, bru) => { // Add hooks shim if bru.hooks exists if (bru.hooks) { - const hooksObject = vm.newObject(); - // Execute handler using the original function handle from the VM // Returns a Promise that resolves when the handler completes (supports async handlers) const executeHandler = async (handlerHandle, vmInstance, data) => { diff --git a/packages/bruno-js/tests/hook-manager.spec.js b/packages/bruno-js/tests/hook-manager.spec.js index 03bc7100d..a54536338 100644 --- a/packages/bruno-js/tests/hook-manager.spec.js +++ b/packages/bruno-js/tests/hook-manager.spec.js @@ -61,12 +61,6 @@ describe('HookManager', () => { expect(() => hookManager.on('test', handler)).toThrow(); }); - it('should register wildcard handlers', () => { - const handler = jest.fn(); - hookManager.on('*', handler); - expect(hookManager.listeners['*']).toContain(handler); - }); - it('should throw if HookManager is disposed', () => { hookManager.dispose(); expect(() => hookManager.on('test', jest.fn())).toThrow(/disposed/); @@ -119,16 +113,6 @@ describe('HookManager', () => { expect(results).toEqual([1, 2]); }); - it('should call all handlers when called with wildcard pattern', async () => { - const handler1 = jest.fn(); - const handler2 = jest.fn(); - hookManager.on('event1', handler1); - hookManager.on('event2', handler2); - await hookManager.call('*', {}); - expect(handler1).toHaveBeenCalled(); - expect(handler2).toHaveBeenCalled(); - }); - it('should handle errors without stopping execution', async () => { const errorHandler = jest.fn(() => { throw new Error('Test error'); }); const normalHandler = jest.fn();