feat(hooks): refactor to initialise hook-manager only once per req execution

This commit is contained in:
Bijin A B
2026-02-12 22:18:32 +05:30
parent 521f7332fd
commit bc09a26fa6
11 changed files with 300 additions and 332 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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
};

View File

@@ -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<object|null>} Execution result or null if error
* @returns {Promise<object|null>} 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<object|null>} 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);

View File

@@ -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
};

View File

@@ -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);
}
}

View File

@@ -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];
}
}

View File

@@ -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
};

View File

@@ -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();
}
}

View File

@@ -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) => {

View File

@@ -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();