Compare commits

...

38 Commits

Author SHA1 Message Date
lohit-bruno
b74b76cc9a refactor(cache): remove unused getCacheStats and purgeCache IPC actions 2026-03-05 18:34:01 +05:30
lohit-bruno
f070812845 refactor(cache): rename httpHttpsAgents to sslSession across preferences and UI 2026-03-05 18:24:01 +05:30
lohit-bruno
adf5721ae0 refactor(cache): rename CLI flag to --cache-ssl-session and disable caching by default
- Rename --disable-http-https-agents-cache to --cache-ssl-session (opt-in)
- Rename disableHttpHttpsAgentsCache to cacheSslSession across CLI and bruno-requests
- Default caching to disabled in both bruno-electron and bruno-cli
- Add cacheSslSession to buildCertsAndProxyConfig for bru.sendRequest
- Update Preferences UI labels to "Cache SSL Session"
2026-03-05 17:18:11 +05:30
lohit-bruno
bad1a02116 fix(proxy): align proxy auth check to use auth.disabled field consistently 2026-03-05 16:58:40 +05:30
lohit-bruno
070c840e52 fix(tls): load client certs into secureContext to prevent silent drop
Add Cache tab to Preferences UI
2026-03-04 20:24:38 +05:30
lohit-bruno
41f3519dcc fix(proxy): fix proxy agent construction and CA cert handling
Three fixes:

1. Proxy agents (HttpsProxyAgent, HttpProxyAgent, SocksProxyAgent) expect
   (proxyUri, options) constructor signature, but the agent cache was packing
   proxyUri into options as a single argument. Fixed the non-timeline code
   path in getOrCreateAgentInternal.

2. HTTP requests through an HTTPS proxy need TLS options (ca certs) to
   validate the proxy's certificate. All getOrCreateHttpAgent call sites
   now pass TLS options when the proxy protocol is HTTPS.

3. Setting the `ca` option on any Node.js TLS connection replaces the
   default OpenSSL trust store entirely. CAs only in the OpenSSL default
   trust store (e.g. /etc/ssl/cert.pem) but not in tls.rootCertificates
   were lost. Fixed by converting `ca` to a secureContext via addCACert(),
   which appends custom CAs on top of the OpenSSL defaults instead of
   replacing them.

Also simplified PatchedHttpsProxyAgent to selectively forward only the
relevant TLS options (cert, key, pfx, passphrase, rejectUnauthorized,
secureContext) to the target TLS upgrade instead of blindly merging all
constructor options.
2026-03-04 19:34:04 +05:30
lohit-bruno
c4c0576660 fix: tests 2026-03-04 19:34:03 +05:30
lohit-bruno
594fc30f9f refactor: simplify UI labels, optimize agent timeline wrapping, silence proxy errors 2026-03-04 19:34:03 +05:30
lohit-bruno
8b08ba1ee9 refactor(cache): rename getOrCreateAgent to getOrCreateHttpsAgent 2026-03-04 19:33:42 +05:30
lohit-bruno
3619448d55 fix(cache): thread disableCache and hostname through all agent-creation paths
- Forward disableHttpHttpsAgentsCache through getHttpHttpsAgents → createAgents
  so OAuth2 token requests and bru.sendRequest honour the CLI flag
- Add hostname to agent cache keys (getAgentCacheKey, getHttpAgentCacheKey)
  for per-host TLS session reuse; extract hostname at every call site in
  run-single-request.js, proxy-util.js, and http-https-agents.ts
- Add extractHostname helper in http-https-agents.ts to safely parse hostnames
- Add test coverage for cert, key, pfx, passphrase, and hostname cache-key
  differentiation in agent-cache.spec.ts
2026-03-04 19:33:42 +05:30
lohit-bruno
b29bdc1e97 fix: tests 2026-03-04 19:33:42 +05:30
lohit-bruno
05bbb54df2 refactor(cache): replace window.ipcRenderer calls with redux actions
Add getCacheStats, purgeCache, and clearHttpHttpsAgentCache thunks to
the app slice. Update the Cache preferences component to dispatch these
actions instead of calling window.ipcRenderer directly.

Also move handleSave and handleSaveRef above useFormik to fix declaration
order — onSubmit closes over handleSaveRef, so the ref must be initialized
before useFormik is called.
2026-03-04 19:33:42 +05:30
lohit-bruno
795fb08d1f feat(cli): add --disable-http-https-agents-cache flag 2026-03-04 19:31:23 +05:30
lohit-bruno
0f05808886 feat(ui): add Cache tab to Preferences 2026-03-04 19:31:23 +05:30
lohit-bruno
592dd7d9e9 feat(redux): add cache.httpHttpsAgents preferences to initial state 2026-03-04 19:26:58 +05:30
lohit-bruno
a5ff9cf144 feat(ipc): add renderer:clear-http-https-agent-cache handler 2026-03-04 19:26:57 +05:30
lohit-bruno
93600b5da8 refactor(agent-cache): use named props for getOrCreateAgent and getOrCreateHttpAgent 2026-03-04 19:26:57 +05:30
lohit-bruno
0f1febc1fe feat(proxy-util): respect httpHttpsAgents cache preference 2026-03-04 19:26:57 +05:30
lohit-bruno
296612dcbc feat(agent-cache): add disableCache option to getOrCreateAgent 2026-03-04 19:26:57 +05:30
lohit-bruno
3e88cd6759 feat(preferences): add cache.httpHttpsAgents.enabled preference 2026-03-04 19:26:57 +05:30
lohit-bruno
37d1b3c5f9 feat(bruno-requests): log when reusing cached agent
- HTTPS agents: "Reusing cached agent (SSL session reuse enabled)"
- HTTP agents: "Reusing cached agent (connection reuse enabled)"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
15c86f8e6b fix(bruno-requests): address code review findings for agent caching
- Fix Buffer hashing bug: properly handle Buffer values in hashValue()
- Add CA array support: new hashCaValue() handles string[] | Buffer[]
- Fix timeline race condition: capture timeline reference in closure
  at createConnection start to isolate concurrent requests
- Fix SSL verify message: check socket.authorized for accurate status
- Fix HTTP/HTTPS agent logic: only set httpsAgent for HTTPS requests
- Add tests for concurrent requests timeline isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
14c66bc42f fix(bruno-electron): improve HTTP agent handling
- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Fix brace style consistency
- Add missing newline at EOF

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
f5a53319e0 fix(bruno-cli): improve HTTP agent handling and error logging
- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Add warning log for system proxy configuration errors
- Fix brace style consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
61a260f71c feat(bruno-requests): use HTTP agent cache for connection reuse
Export getOrCreateHttpAgent and use it in http-https-agents for
HTTP requests to enable connection pooling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
c6f3007dbf refactor(bruno-requests): extract shared agent caching logic
Add getOrCreateAgentInternal helper to reduce code duplication
between getOrCreateAgent and getOrCreateHttpAgent functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
8605810747 feat(bruno-requests): add HTTP agent timeline support
Add createTimelineHttpAgentClass for logging HTTP connection events
including proxy usage, DNS lookups, and connection establishment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
c2bad2e2c8 feat(bruno-cli): use agent cache for SSL session reuse
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
dbc1d11e23 refactor(bruno-electron): use shared agent cache from bruno-requests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
9df4b04ae8 feat(bruno-requests): integrate agent cache into http-https-agents
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
f51a7b2ded test(bruno-requests): add tests for agent cache
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
e2d3b4dbe8 feat(bruno-requests): add agent cache for SSL session reuse
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
28c4e24e2e feat(bruno-requests): add timeline agent for TLS event logging
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
karthik
9cbc58df70 fix: enable SSL session caching for faster consecutive requests (#6929)
* fix: enable SSL session caching for faster consecutive requests

Previously, Bruno created a new HTTPS agent for every request, which meant
SSL/TLS sessions couldn't be reused. This caused the full TLS handshake
(~450ms) to run on every request, even to the same endpoint.

Changes:
- Add agent caching based on TLS configuration (certs, proxy, SSL options)
- Reuse cached agents for requests with matching configuration
- SSL sessions are now cached and reused, significantly reducing
  response time for consecutive requests to the same host

The fix maintains backward compatibility:
- Timeline logging moved to setup phase (before agent creation)
- Proxy and SSL validation behavior unchanged
- Added clearAgentCache() for testing and configuration changes

Fixes #5574

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address review feedback for SSL session caching

- Add passphrase to cache key to prevent incorrect agent reuse
- Add MAX_AGENT_CACHE_SIZE (100) with LRU-style eviction
- Use consistent node: prefix for crypto import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: lohit <lohit@usebruno.com>
2026-03-04 19:26:57 +05:30
Chirag Chandrashekhar
0b7cd0e540 Revert "Performance/file parse and mount (#6975)" (#7360)
* Revert "Performance/file parse and mount (#6975)"

This reverts commit f76f487211.

* fix: import duplication

* Revert "fix(batch-events): fix order of directory file and folder events (#7300)"

This reverts commit bf4af42a25.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-03-04 19:20:26 +05:30
sanish chirayath
17c3dc0e2b refactor: comment out unused APIs (#7323)
* refactor: comment out unused API hints in autocomplete.js

* refactor: comment out unused API translations in postman and bruno translators

Temporarily disable certain API translations due to UI update issues affecting their functionality. A note has been added to restore these translations once the UI fixes are implemented.

* refactor: temporarily skip tests for collection variable translations due to UI update issues

Commented out tests related to `setCollectionVar`, `deleteCollectionVar`, and related functionalities until the necessary UI updates are implemented. A note has been added to restore these tests once the fixes are live.

* refactor: comment out variable deletion and retrieval methods due to UI sync issues

* revert: ping.bru

* refactor: update postman translation tests to enable previously skipped cases
2026-03-04 18:00:10 +05:30
Bijin A B
75c3ab8032 chore: update coderabbit instructions to make sure the code is os agnostic (#7355) 2026-03-04 13:50:08 +05:30
gopu-bruno
6d86c76b21 feat: inline create collection and workspace editor (#7324)
* feat: inline create collection and workspace editor

* refactor: use inline collection creation from workspace overview

* fix: improve inline collection creation UX from workspace overview

* fix: update E2E tests for inline collection creation flow

* fix: update default location test for inline collection creation flow

* fix: derive inline workspace/collection names from filesystem

* feat: inline workspace create form manage workspace

* feat: prefill create modal with name

* fix: minor code style fixes

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-03-04 13:25:30 +05:30
66 changed files with 2253 additions and 2133 deletions

View File

@@ -23,6 +23,19 @@ reviews:
drafts: false
base_branches: ['main', 'release/*']
path_instructions:
- path: '**/*'
instructions: |
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
- Never assume case-sensitive or case-insensitive filesystems
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
- Use `os.tmpdir()` instead of hardcoding `/tmp`
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
- path: 'tests/**/**.*'
instructions: |
Review the following e2e test code written using the Playwright test library. Ensure that:

View File

@@ -13,8 +13,7 @@
"api/*": ["src/api/*"],
"pageComponents/*": ["src/pageComponents/*"],
"providers/*": ["src/providers/*"],
"utils/*": ["src/utils/*"],
"store/*": ["src/store/*"]
"utils/*": ["src/utils/*"]
}
},
"exclude": ["node_modules", "dist"]

View File

@@ -6,9 +6,10 @@ import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import get from 'lodash/get';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
@@ -150,9 +151,20 @@ const AppTitleBar = () => {
}
};
const handleCreateWorkspace = () => {
setCreateWorkspaceModalOpen(true);
};
const handleCreateWorkspace = useCallback(async () => {
const defaultLocation = get(preferences, 'general.defaultLocation', '');
if (!defaultLocation) {
setCreateWorkspaceModalOpen(true);
return;
}
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}
}, [preferences, dispatch]);
const handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
@@ -240,7 +252,7 @@ const AppTitleBar = () => {
);
return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import React from 'react';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ShareCollection from 'components/ShareCollection/index';
@@ -10,13 +11,10 @@ import StyledWrapper from './StyledWrapper';
const Info = ({ collection }) => {
const dispatch = useDispatch();
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
const isCollectionLoading = collection.isLoading;
const totalRequestsInCollection = useMemo(
() => getTotalRequestCountInCollection(collection),
[collection.items]
);
const isCollectionLoading = areItemsLoading(collection);
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
@@ -97,9 +95,7 @@ const Info = ({ collection }) => {
<div className="font-medium">Requests</div>
<div className="mt-1 text-muted">
{
isCollectionLoading
? 'Loading requests...'
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
}
</div>
</div>

View File

@@ -3,8 +3,9 @@ import { useSelector, useDispatch } from 'react-redux';
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { createWorkspaceWithUniqueName, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { sortWorkspaces } from 'utils/workspaces';
@@ -59,6 +60,21 @@ const ManageWorkspace = () => {
setDeleteWorkspaceModal({ open: true, workspace });
};
const handleCreateWorkspace = async () => {
const defaultLocation = get(preferences, 'general.defaultLocation', '');
if (!defaultLocation) {
setCreateWorkspaceModalOpen(true);
return;
}
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}
};
return (
<StyledWrapper>
{createWorkspaceModalOpen && (
@@ -86,7 +102,7 @@ const ManageWorkspace = () => {
</div>
<span className="header-title">Manage Workspace</span>
</div>
<Button size="sm" onClick={() => setCreateWorkspaceModalOpen(true)} icon={<IconPlus size={14} strokeWidth={2} />}>
<Button size="sm" onClick={handleCreateWorkspace} icon={<IconPlus size={14} strokeWidth={2} />}>
Create Workspace
</Button>
</div>

View File

@@ -2,66 +2,12 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.cache-stats {
padding: 1rem;
border-radius: ${(props) => props.theme.border.radius.md};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
margin-bottom: 1rem;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid ${(props) => props.theme.input.border};
&:last-child {
border-bottom: none;
form.bruno-form {
label {
font-size: 0.8125rem;
}
}
.stat-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.stat-value {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
}
.purge-button {
padding: 0.5rem 1rem;
border-radius: ${(props) => props.theme.border.radius.sm};
font-size: ${(props) => props.theme.font.size.sm};
cursor: pointer;
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
&:hover:not(:disabled) {
border-color: ${(props) => props.theme.input.focusBorder};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin-top: 0.5rem;
}
.section-title {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
`;
export default StyledWrapper;

View File

@@ -1,87 +1,120 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import {
savePreferences,
clearHttpHttpsAgentCache
} from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import get from 'lodash/get';
const cacheSchema = Yup.object().shape({
sslSession: Yup.object({
enabled: Yup.boolean()
})
});
const Cache = () => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [purging, setPurging] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
const fetchStats = useCallback(async () => {
try {
const cacheStats = await window.ipcRenderer.invoke('renderer:get-cache-stats');
setStats(cacheStats);
} catch (error) {
console.error('Error fetching cache stats:', error);
setStats({ error: error.message });
} finally {
setLoading(false);
const handleSave = useCallback(
(newCachePreferences) => {
dispatch(
savePreferences({
...preferences,
cache: newCachePreferences
})
).catch(() => toast.error('Failed to update cache preferences'));
},
[dispatch, preferences]
);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const formik = useFormik({
initialValues: {
sslSession: {
enabled: get(preferences, 'cache.sslSession.enabled', false)
}
},
validationSchema: cacheSchema,
onSubmit: async (values) => {
try {
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
handleSave(newPreferences);
} catch (error) {
console.error('Cache preferences validation error:', error.message);
}
}
}, []);
});
const debouncedSave = useCallback(
debounce((values) => {
cacheSchema
.validate(values, { abortEarly: true })
.then((validatedValues) => handleSaveRef.current(validatedValues))
.catch(() => {});
}, 500),
[]
);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const handlePurgeCache = async () => {
setPurging(true);
try {
const result = await window.ipcRenderer.invoke('renderer:purge-cache');
if (result.success) {
toast.success('Cache purged successfully');
await fetchStats();
} else {
toast.error(result.error || 'Failed to purge cache');
}
} catch (error) {
console.error('Error purging cache:', error);
toast.error('Failed to purge cache');
} finally {
setPurging(false);
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
const handleAgentCachingChange = (e) => {
formik.handleChange(e);
// Immediately evict all cached agents when caching is disabled
if (!e.target.checked) {
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
}
};
const handleResetCache = () => {
dispatch(clearHttpHttpsAgentCache())
.then(() => toast.success('ssl session cache cleared'))
.catch(() => toast.error('Failed to clear ssl session cache'));
};
return (
<StyledWrapper className="w-full">
<div className="section-title">Collection Cache</div>
<p className="description mb-4">
Bruno caches parsed collection files to improve loading performance. Clearing the cache will cause collections to be fully re-parsed on next load.
</p>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
<div className="cache-stats">
{loading ? (
<div className="stat-item">
<span className="stat-label">Loading...</span>
</div>
) : stats?.error ? (
<div className="stat-item">
<span className="stat-label">Error: {stats.error}</span>
</div>
) : (
<>
<div className="stat-item">
<span className="stat-label">Cached Collections</span>
<span className="stat-value">{stats?.totalCollections ?? 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Cached Files</span>
<span className="stat-value">{stats?.totalFiles ?? 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Cache Version</span>
<span className="stat-value">{stats?.version ?? 'N/A'}</span>
</div>
</>
)}
</div>
<div className="flex items-center my-2">
<input
id="sslSession.enabled"
type="checkbox"
name="sslSession.enabled"
checked={formik.values.sslSession.enabled}
onChange={handleAgentCachingChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
Enable SSL session caching
</label>
</div>
<div className="text-xs mt-1 ml-6 opacity-70">
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
request.
</div>
<button
className="purge-button"
onClick={handlePurgeCache}
disabled={purging || loading}
>
{purging ? 'Purging...' : 'Purge Cache'}
</button>
<div className="mt-6">
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
Clear
</button>
</div>
</form>
</StyledWrapper>
);
};

View File

@@ -20,9 +20,9 @@ import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
import Cache from './Cache';
import StyledWrapper from './StyledWrapper';
import Cache from './Cache/index';
const Preferences = () => {
const dispatch = useDispatch();
@@ -64,13 +64,13 @@ const Preferences = () => {
return <Beta />;
}
case 'cache': {
return <Cache />;
}
case 'support': {
return <Support />;
}
case 'cache': {
return <Cache />;
}
}
};

View File

@@ -15,6 +15,7 @@ import {
IconUpload
} from '@tabler/icons';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
@@ -53,6 +54,21 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
// Auto-enter rename mode when workspace is newly created
useEffect(() => {
if (isScratchCollection && currentWorkspace?.isNewlyCreated) {
dispatch(updateWorkspace({ uid: currentWorkspace.uid, isNewlyCreated: false }));
setIsRenamingWorkspace(true);
setWorkspaceNameInput(currentWorkspace.name || '');
setWorkspaceNameError('');
const timer = setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
return () => clearTimeout(timer);
}
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
const handleCancelWorkspaceRename = useCallback(() => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');

View File

@@ -67,7 +67,7 @@ const Collection = ({ collection, searchText }) => {
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [showEmptyState, setShowEmptyState] = useState(false);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const isLoading = collection.isLoading;
const collectionRef = useRef(null);
const itemCount = collection.items?.length || 0;

View File

@@ -1,24 +1,18 @@
import { useState } from 'react';
import { useTheme } from '../../../../providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { openCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import styled from 'styled-components';
import CreateCollection from 'components/Sidebar/CreateCollection';
import StyledWrapper from './StyledWrapper';
const LinkStyle = styled.span`
color: ${(props) => props.theme['text-link']};
`;
const CreateOrOpenCollection = () => {
const CreateOrOpenCollection = ({ onCreateClick }) => {
const { theme } = useTheme();
const dispatch = useDispatch();
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const handleOpenCollection = () => {
dispatch(openCollection()).catch(
@@ -32,7 +26,7 @@ const CreateOrOpenCollection = () => {
<LinkStyle
className="underline text-link cursor-pointer"
theme={theme}
onClick={() => setCreateCollectionModalOpen(true)}
onClick={onCreateClick}
>
Create
</LinkStyle>
@@ -45,12 +39,6 @@ const CreateOrOpenCollection = () => {
return (
<StyledWrapper className="px-2 mt-4">
{createCollectionModalOpen ? (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
/>
) : null}
<div className="text-xs text-center">
<div>No collections found.</div>
<div className="mt-2">

View File

@@ -0,0 +1,89 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.inline-collection-creator {
display: flex;
align-items: center;
gap: 4px;
height: 1.6rem;
padding-left: 8px;
padding-right: 4px;
}
.input-wrapper {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 3px;
background: ${(props) => props.theme.input.bg};
&:focus-within {
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.inline-collection-input {
font-size: 13px;
padding: 1px 4px;
border: none;
background: transparent;
color: ${(props) => props.theme.text};
outline: none;
flex: 1;
min-width: 0;
}
.cog-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 100%;
border: none;
cursor: pointer;
background: transparent;
color: ${(props) => props.theme.text};
opacity: 0.5;
&:hover {
opacity: 1;
}
}
.inline-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 3px;
cursor: pointer;
background: transparent;
color: ${(props) => props.theme.text};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&.save {
color: ${(props) => props.theme.colors.text.green};
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,175 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconCheck, IconX, IconSettings } from '@tabler/icons';
import get from 'lodash/get';
import toast from 'react-hot-toast';
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import { multiLineMsg } from 'utils/common';
import { formatIpcError } from 'utils/common/error';
import StyledWrapper from './StyledWrapper';
const InlineCollectionCreator = ({ onComplete, onCancel, onOpenAdvanced }) => {
const inputRef = useRef(null);
const containerRef = useRef(null);
const dispatch = useDispatch();
const [isCreating, setIsCreating] = useState(false);
const openingAdvancedRef = useRef(false);
const clickedOutsideRef = useRef(false);
const preferences = useSelector((state) => state.app.preferences);
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
const activeWorkspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
useEffect(() => {
const focusAndSelect = (value) => {
if (!inputRef.current) {
return;
}
if (value) {
inputRef.current.value = value;
}
inputRef.current.focus();
inputRef.current.select();
};
if (defaultLocation) {
window.ipcRenderer?.invoke('renderer:find-unique-folder-name', 'untitled collection', defaultLocation)
?.then((name) => focusAndSelect(name))
?.catch(() => focusAndSelect());
} else {
focusAndSelect();
}
}, [defaultLocation]);
const handleCancel = () => {
if (isCreating || openingAdvancedRef.current) return;
onCancel();
};
const handleCreate = useCallback(async () => {
const fromOutside = clickedOutsideRef.current;
clickedOutsideRef.current = false;
if (isCreating || openingAdvancedRef.current) return;
const name = inputRef.current?.value?.trim();
if (!name) {
if (fromOutside) {
onCancel();
} else {
toast.error('Collection name is required');
}
return;
}
if (!validateName(name)) {
toast.error(validateNameError(name));
if (fromOutside) {
onCancel();
}
return;
}
if (!defaultLocation) {
toast.error('Please set a default location in Preferences > General');
onCancel();
return;
}
setIsCreating(true);
try {
const folderName = sanitizeName(name);
await dispatch(createCollection(name, folderName, defaultLocation, { format: DEFAULT_COLLECTION_FORMAT }));
toast.success('Collection created!');
onComplete();
} catch (e) {
toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e)));
setIsCreating(false);
}
}, [isCreating, defaultLocation, dispatch, onCancel, onComplete]);
// Click outside to create
useEffect(() => {
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
clickedOutsideRef.current = true;
handleCreate();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [handleCreate]);
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreate();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
return (
<StyledWrapper>
<div className="inline-collection-creator" ref={containerRef}>
<div className="input-wrapper">
<input
ref={inputRef}
type="text"
className="inline-collection-input"
defaultValue="untitled collection"
onKeyDown={handleKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
disabled={isCreating}
/>
<button
className="cog-btn"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
openingAdvancedRef.current = true;
onOpenAdvanced(inputRef.current?.value?.trim());
}}
title="Advanced options"
disabled={isCreating}
>
<IconSettings size={13} strokeWidth={1.5} />
</button>
</div>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleCreate}
onMouseDown={(e) => e.preventDefault()}
title="Create"
disabled={isCreating}
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancel}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
disabled={isCreating}
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
</StyledWrapper>
);
};
export default InlineCollectionCreator;

View File

@@ -1,18 +1,17 @@
import React, { useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import CollectionSearch from './CollectionSearch/index';
import InlineCollectionCreator from './InlineCollectionCreator';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
const Collections = ({ showSearch }) => {
const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => {
const [searchText, setSearchText] = useState('');
const { collections } = useSelector((state) => state.collections);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default');
@@ -30,24 +29,32 @@ const Collections = ({ showSearch }) => {
if (!workspaceCollections || !workspaceCollections.length) {
return (
<StyledWrapper>
<CreateOrOpenCollection />
{isCreatingCollection && (
<InlineCollectionCreator
onComplete={onDismissCreate}
onCancel={onDismissCreate}
onOpenAdvanced={onOpenAdvancedCreate}
/>
)}
{!isCreatingCollection && <CreateOrOpenCollection onCreateClick={onCreateClick} />}
</StyledWrapper>
);
}
return (
<StyledWrapper data-testid="collections">
{createCollectionModalOpen ? (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
/>
) : null}
{showSearch && (
<CollectionSearch searchText={searchText} setSearchText={setSearchText} />
)}
<div className="collections-list">
{isCreatingCollection && (
<InlineCollectionCreator
onComplete={onDismissCreate}
onCancel={onDismissCreate}
onOpenAdvanced={onOpenAdvancedCreate}
/>
)}
{workspaceCollections && workspaceCollections.length
? workspaceCollections.map((c) => {
return (

View File

@@ -18,7 +18,7 @@ import StyledWrapper from './StyledWrapper';
import get from 'lodash/get';
import Button from 'ui/Button';
const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) => {
const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation, initialCollectionName = '' }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
@@ -37,8 +37,8 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
const formik = useFormik({
enableReinitialize: true,
initialValues: {
collectionName: '',
collectionFolderName: '',
collectionName: initialCollectionName,
collectionFolderName: initialCollectionName ? sanitizeName(initialCollectionName) : '',
collectionLocation: defaultLocation || '',
format: DEFAULT_COLLECTION_FORMAT
},
@@ -86,9 +86,13 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
};
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
const timer = setTimeout(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timer);
}, [inputRef]);
const AdvancedOptions = forwardRef((props, ref) => {

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
@@ -46,11 +47,13 @@ const CollectionsSection = () => {
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const { isCreatingCollection } = useSelector((state) => state.app);
const preferences = useSelector((state) => state.app.preferences);
const [collectionsToClose, setCollectionsToClose] = useState([]);
const [importData, setImportData] = useState(null);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [advancedCreateName, setAdvancedCreateName] = useState('');
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
@@ -241,13 +244,19 @@ const CollectionsSection = () => {
});
};
const handleOpenAdvancedCreate = (name) => {
dispatch(setIsCreatingCollection(false));
setAdvancedCreateName(name || '');
setCreateCollectionModalOpen(true);
};
const addDropdownItems = [
{
id: 'create',
leftSection: IconPlus,
label: 'Create collection',
onClick: () => {
setCreateCollectionModalOpen(true);
dispatch(setIsCreatingCollection(true));
}
},
{
@@ -359,7 +368,11 @@ const CollectionsSection = () => {
)}
{createCollectionModalOpen && (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
onClose={() => {
setCreateCollectionModalOpen(false);
setAdvancedCreateName('');
}}
initialCollectionName={advancedCreateName}
/>
)}
{importCollectionModalOpen && (
@@ -396,7 +409,13 @@ const CollectionsSection = () => {
icon={IconBox}
actions={sectionActions}
>
<Collections showSearch={showSearch} />
<Collections
showSearch={showSearch}
isCreatingCollection={isCreatingCollection}
onCreateClick={() => dispatch(setIsCreatingCollection(true))}
onDismissCreate={() => dispatch(setIsCreatingCollection(false))}
onOpenAdvancedCreate={handleOpenAdvancedCreate}
/>
</SidebarSection>
</>
);

View File

@@ -2,8 +2,8 @@ import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconPlus, IconFolder, IconDownload } from '@tabler/icons';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import { setIsCreatingCollection, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
@@ -16,8 +16,8 @@ import StyledWrapper from './StyledWrapper';
const WorkspaceOverview = ({ workspace }) => {
const dispatch = useDispatch();
const { globalEnvironments } = useSelector((state) => state.globalEnvironments);
const { sidebarCollapsed, isCreatingCollection } = useSelector((state) => state.app);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [importData, setImportData] = useState(null);
@@ -29,6 +29,10 @@ const WorkspaceOverview = ({ workspace }) => {
const workspaceEnvironmentsCount = globalEnvironments?.length || 0;
const handleCreateCollection = async () => {
if (isCreatingCollection) {
return;
}
if (!workspace?.pathname) {
toast.error('Workspace path not found');
return;
@@ -37,7 +41,10 @@ const WorkspaceOverview = ({ workspace }) => {
try {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:ensure-collections-folder', workspace.pathname);
setCreateCollectionModalOpen(true);
if (sidebarCollapsed) {
dispatch(toggleSidebarCollapse());
}
dispatch(setIsCreatingCollection(true));
} catch (error) {
console.error('Error ensuring collections folder exists:', error);
toast.error('Error preparing workspace for collection creation');
@@ -87,10 +94,6 @@ const WorkspaceOverview = ({ workspace }) => {
return (
<StyledWrapper>
{createCollectionModalOpen && (
<CreateCollection onClose={() => setCreateCollectionModalOpen(false)} />
)}
{importCollectionModalOpen && (
<ImportCollection
onClose={() => setImportCollectionModalOpen(false)}
@@ -142,6 +145,7 @@ const WorkspaceOverview = ({ workspace }) => {
size="sm"
icon={<IconPlus size={14} strokeWidth={1.5} />}
onClick={handleCreateCollection}
disabled={isCreatingCollection}
>
Create Collection
</Button>

View File

@@ -5,7 +5,6 @@ import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import ConfirmAppClose from './ConfirmAppClose';
import useIpcEvents from './useIpcEvents';
import useTelemetry from './useTelemetry';
import useParsedFileCacheIpc from './useParsedFileCacheIpc';
import StyledWrapper from './StyledWrapper';
import { version } from '../../../package.json';
@@ -14,7 +13,6 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => {
useTelemetry({ version });
useIpcEvents();
useParsedFileCacheIpc();
const dispatch = useDispatch();
useEffect(() => {

View File

@@ -11,7 +11,6 @@ import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
collectionAddFileEvent,
collectionBatchAddItems,
collectionChangeFileEvent,
collectionRenamedEvent,
collectionUnlinkDirectoryEvent,
@@ -102,50 +101,6 @@ const useIpcEvents = () => {
}
};
// Batch handler for collection tree updates (performance optimization)
// Uses a single Redux dispatch to process all items, avoiding multiple re-renders
const _collectionTreeBatchUpdated = (batch) => {
if (!batch || !Array.isArray(batch) || batch.length === 0) {
return;
}
if (window.__IS_DEV__) {
console.log('Batch update received:', batch.length, 'items');
}
// Separate batch items into those that can be bulk-processed vs those that need individual handling
const bulkItems = []; // addFile, addDir - can be processed in single reducer
const individualItems = []; // change, unlink, etc - need individual dispatches
batch.forEach(({ eventType, payload }) => {
if (eventType === 'addDir' || eventType === 'addFile') {
bulkItems.push({ eventType, payload });
} else {
individualItems.push({ eventType, payload });
}
});
// Process bulk items in a single dispatch (addFile and addDir)
if (bulkItems.length > 0) {
dispatch(collectionBatchAddItems({ items: bulkItems }));
}
// Process remaining items individually (these are typically rare during mount)
individualItems.forEach(({ eventType, payload }) => {
if (eventType === 'change') {
dispatch(collectionChangeFileEvent({ file: payload }));
} else if (eventType === 'unlink') {
dispatch(collectionUnlinkFileEvent({ file: payload }));
} else if (eventType === 'unlinkDir') {
dispatch(collectionUnlinkDirectoryEvent({ directory: payload }));
} else if (eventType === 'addEnvironmentFile') {
dispatch(collectionAddEnvFileEvent(payload));
} else if (eventType === 'unlinkEnvironmentFile') {
dispatch(collectionUnlinkEnvFileEvent(payload));
}
});
};
const _apiSpecTreeUpdated = (type, val) => {
if (window.__IS_DEV__) {
console.log('API Spec update:', type);
@@ -163,8 +118,6 @@ const useIpcEvents = () => {
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
const removeCollectionTreeBatchUpdateListener = ipcRenderer.on('main:collection-tree-batch-updated', _collectionTreeBatchUpdated);
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
@@ -387,7 +340,6 @@ const useIpcEvents = () => {
return () => {
removeCollectionTreeUpdateListener();
removeCollectionTreeBatchUpdateListener();
removeApiSpecTreeUpdateListener();
removeOpenCollectionListener();
removeOpenWorkspaceListener();

View File

@@ -1,60 +0,0 @@
import { useEffect } from 'react';
import { isElectron } from 'utils/common/platform';
import { parsedFileCacheStore } from 'store/parsedFileCache';
const useParsedFileCacheIpc = () => {
useEffect(() => {
if (!isElectron()) {
return () => {};
}
const { ipcRenderer } = window;
const handleCacheRequest = async (operation, requestId, ...args) => {
try {
let result = null;
switch (operation) {
case 'getEntry':
result = await parsedFileCacheStore.getEntry(...args);
break;
case 'setEntry':
await parsedFileCacheStore.setEntry(...args);
break;
case 'invalidate':
await parsedFileCacheStore.invalidate(...args);
break;
case 'invalidateCollection':
await parsedFileCacheStore.invalidateCollection(...args);
break;
case 'invalidateDirectory':
await parsedFileCacheStore.invalidateDirectory(...args);
break;
case 'getStats':
result = await parsedFileCacheStore.getStats();
break;
case 'clear':
await parsedFileCacheStore.clear();
break;
default:
throw new Error(`Unknown cache operation: ${operation}`);
}
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: true, data: result });
} catch (error) {
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: false, error: error.message });
}
};
const removeListener = ipcRenderer.on('main:parsed-file-cache-request', handleCacheRequest);
// Prune old cache entries on startup
parsedFileCacheStore.prune().catch((err) => {
console.error('ParsedFileCacheStore: Error during startup prune:', err);
});
return () => {
removeListener();
};
}, []);
};
export default useParsedFileCacheIpc;

View File

@@ -4,7 +4,7 @@ import filter from 'lodash/filter';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { collectionAddFileEvent, collectionChangeFileEvent, collectionBatchAddItems } from 'providers/ReduxStore/slices/collections';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
import { taskTypes } from './utils';
@@ -51,57 +51,6 @@ taskMiddleware.startListening({
}
});
/*
* When files are added via batch processing (e.g., during collection mount or when new files are created),
* we need to check if any of the added files match pending OPEN_REQUEST tasks.
* This handles the case where file additions go through the batch reducer instead of individual events.
*/
taskMiddleware.startListening({
actionCreator: collectionBatchAddItems,
effect: (action, listenerApi) => {
const state = listenerApi.getState();
const items = action.payload?.items || [];
// Extract all addFile events from the batch
const addFileItems = items.filter((item) => item.eventType === 'addFile');
if (addFileItems.length === 0) return;
const openRequestTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_REQUEST });
if (openRequestTasks.length === 0) return;
each(addFileItems, ({ payload: file }) => {
const collectionUid = file?.meta?.collectionUid;
if (!collectionUid) return;
each(openRequestTasks, (task) => {
if (collectionUid === task.collectionUid && file?.meta?.pathname === task.itemPathname) {
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
const item = findItemInCollectionByPathname(collection, task.itemPathname);
const isTransient = item?.isTransient ?? false;
if (item) {
listenerApi.dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item),
preview: !isTransient
})
);
}
}
listenerApi.dispatch(
removeTaskFromQueue({
taskUid: task.uid
})
);
}
});
});
}
});
/*
* When an example is created or cloned, a task to open the example is added to the queue.
* We wait for the File IO to complete, after which the "collectionChangeFileEvent" gets dispatched.

View File

@@ -43,6 +43,11 @@ const initialState = {
autoSave: {
enabled: false,
interval: 1000
},
cache: {
sslSession: {
enabled: false
}
}
},
generateCode: {
@@ -61,7 +66,8 @@ const initialState = {
envVarSearch: {
collection: { query: '', expanded: false },
global: { query: '', expanded: false }
}
},
isCreatingCollection: false
};
export const appSlice = createSlice({
@@ -157,6 +163,9 @@ export const appSlice = createSlice({
setEnvVarSearchExpanded: (state, { payload: { context, expanded } }) => {
if (!state.envVarSearch[context]) return;
state.envVarSearch[context].expanded = expanded;
},
setIsCreatingCollection: (state, action) => {
state.isCreatingCollection = action.payload;
}
},
extraReducers: (builder) => {
@@ -200,7 +209,8 @@ export const {
setGitVersion,
setClipboard,
setEnvVarSearchQuery,
setEnvVarSearchExpanded
setEnvVarSearchExpanded,
setIsCreatingCollection
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {
@@ -296,4 +306,11 @@ export const refreshSystemProxy = () => (dispatch, getState) => {
});
};
export const clearHttpHttpsAgentCache = () => () => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:clear-http-https-agent-cache').then(resolve).catch(reject);
});
};
export default appSlice.reducer;

View File

@@ -162,7 +162,6 @@ export const collectionsSlice = createSlice({
collection.settingsSelectedTab = 'overview';
collection.folderLevelSettingsSelectedTab = {};
collection.allTags = []; // Initialize collection-level tags
collection.isLoading = false;
// Collection mount status is used to track the mount status of the collection
// values can be 'unmounted', 'mounting', 'mounted'
@@ -2770,224 +2769,6 @@ export const collectionsSlice = createSlice({
addDepth(collection.items);
}
},
// Batch reducer for adding multiple files/directories in a single state update
// This is a performance optimization to avoid multiple re-renders during collection mount
collectionBatchAddItems: (state, action) => {
const { items } = action.payload;
if (!items || !Array.isArray(items) || items.length === 0) {
return;
}
// Group items by collection to minimize lookups
const itemsByCollection = new Map();
for (const item of items) {
const collectionUid = item.payload?.meta?.collectionUid || item.payload?.collectionUid;
if (!collectionUid) continue;
if (!itemsByCollection.has(collectionUid)) {
itemsByCollection.set(collectionUid, []);
}
itemsByCollection.get(collectionUid).push(item);
}
// Process each collection's items
for (const [collectionUid, collectionItems] of itemsByCollection) {
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) continue;
const tempDirectory = state.tempDirectories?.[collectionUid];
const folderIndex = new Map();
const folderStack = [...collection.items];
while (folderStack.length) {
const item = folderStack.pop();
if (item?.type === 'folder' && item.pathname) {
folderIndex.set(item.pathname, item);
if (item.items && item.items.length) {
folderStack.push(...item.items);
}
}
}
for (const { eventType, payload } of collectionItems) {
if (eventType === 'addDir') {
const dir = payload;
const isTransientDir = tempDirectory && dir.meta.pathname.startsWith(tempDirectory);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
let lastFolder = null;
for (const directoryName of subDirectories) {
currentPath = path.join(currentPath, directoryName);
let childItem = folderIndex.get(currentPath);
if (!childItem) {
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
}
if (!childItem) {
childItem = {
uid: dir?.meta?.uid || uuid(),
pathname: currentPath,
name: dir?.meta?.name || directoryName,
seq: dir?.meta?.seq,
filename: directoryName,
collapsed: true,
type: 'folder',
items: [],
isTransient: isTransientDir
};
currentSubItems.push(childItem);
folderIndex.set(currentPath, childItem);
} else if (isTransientDir && !childItem.isTransient) {
childItem.isTransient = true;
}
currentSubItems = childItem.items;
lastFolder = childItem;
}
if (lastFolder) {
if (dir?.meta?.name) {
lastFolder.name = dir.meta.name;
}
if (dir?.meta?.seq) {
lastFolder.seq = dir.meta.seq;
}
}
continue;
}
if (eventType === 'addFile') {
const file = payload;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
const isFolderRoot = file.meta.folderRoot ? true : false;
const isTransientFile = tempDirectory && file.meta.pathname.startsWith(tempDirectory);
if (isCollectionRoot) {
collection.root = mergeRootWithPreservedUids(collection.root, file.data);
continue;
}
if (isFolderRoot) {
const folderPath = path.dirname(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, folderPath);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
let folderItem = folderIndex.get(folderPath);
if (!folderItem) {
for (const directoryName of subDirectories) {
currentPath = path.join(currentPath, directoryName);
let childItem = folderIndex.get(currentPath);
if (!childItem) {
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
}
if (!childItem) {
childItem = {
uid: uuid(),
pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
items: [],
isTransient: isTransientFile
};
currentSubItems.push(childItem);
folderIndex.set(currentPath, childItem);
} else if (isTransientFile && !childItem.isTransient) {
childItem.isTransient = true;
}
currentSubItems = childItem.items;
if (currentPath === folderPath) {
folderItem = childItem;
}
}
}
if (folderItem) {
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data);
if (file?.data?.meta?.seq) {
folderItem.seq = file.data?.meta?.seq;
}
}
continue;
}
const dirname = path.dirname(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
currentPath = path.join(currentPath, directoryName);
let childItem = folderIndex.get(currentPath);
if (!childItem) {
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
}
if (!childItem) {
childItem = {
uid: uuid(),
pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
items: [],
isTransient: isTransientFile
};
currentSubItems.push(childItem);
folderIndex.set(currentPath, childItem);
} else if (isTransientFile && !childItem.isTransient) {
childItem.isTransient = true;
}
currentSubItems = childItem.items;
}
if (file.meta.name !== 'folder.bru' && !currentSubItems.find((f) => f.name === file.meta.name)) {
const currentItem = find(currentSubItems, (i) => i.uid === file.data.uid);
if (currentItem) {
currentItem.name = file.data.name;
currentItem.type = file.data.type;
currentItem.seq = file.data.seq;
currentItem.tags = file.data.tags;
currentItem.request = mergeRequestWithPreservedUids(currentItem.request, file.data.request);
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
currentItem.settings = file.data.settings;
currentItem.examples = file.data.examples;
currentItem.draft = null;
currentItem.partial = file.partial;
currentItem.loading = file.loading;
currentItem.size = file.size;
currentItem.error = file.error;
currentItem.isTransient = isTransientFile;
} else {
currentSubItems.push({
uid: file.data.uid,
name: file.data.name,
type: file.data.type,
seq: file.data.seq,
tags: file.data.tags,
request: file.data.request,
settings: file.data.settings,
examples: file.data.examples,
filename: file.meta.name,
pathname: file.meta.pathname,
draft: null,
partial: file.partial,
loading: file.loading,
size: file.size,
error: file.error,
isTransient: isTransientFile
});
}
}
}
}
// Call addDepth once per collection after all items are added
addDepth(collection.items);
}
},
collectionChangeFileEvent: (state, action) => {
const { file } = action.payload;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
@@ -3002,33 +2783,7 @@ export const collectionsSlice = createSlice({
if (isFolderRoot) {
const folderPath = path.dirname(file.meta.pathname);
let folderItem = findItemInCollectionByPathname(collection, folderPath);
if (!folderItem && collection) {
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, folderPath);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
items: []
};
currentSubItems.push(childItem);
}
currentSubItems = childItem.items;
if (currentPath === folderPath) {
folderItem = childItem;
}
}
}
const folderItem = findItemInCollectionByPathname(collection, folderPath);
if (folderItem) {
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
@@ -3907,7 +3662,6 @@ export const {
updateCollectionProtobuf,
collectionAddFileEvent,
collectionAddDirectoryEvent,
collectionBatchAddItems,
collectionChangeFileEvent,
collectionUnlinkFileEvent,
collectionUnlinkDirectoryEvent,

View File

@@ -15,6 +15,7 @@ import { removeCollection, addTransientDirectory, updateCollectionMountStatus }
import { updateGlobalEnvironments } from '../global-environments';
import { addTab, focusTab } from '../tabs';
import { normalizePath } from 'utils/common/path';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
const { ipcRenderer } = window;
@@ -50,6 +51,21 @@ const transformCollection = async (collection, type) => {
}
};
/**
* Creates a workspace with a unique name under the given location
*/
export const createWorkspaceWithUniqueName = (location) => {
return async (dispatch) => {
const name = await ipcRenderer?.invoke('renderer:find-unique-folder-name', 'untitled workspace', location) || 'untitled workspace';
const folderName = sanitizeName(name);
const result = await dispatch(createWorkspaceAction(name, folderName, location));
if (result?.workspaceUid) {
dispatch(updateWorkspace({ uid: result.workspaceUid, isNewlyCreated: true }));
}
return result;
};
};
export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => {
return async (dispatch) => {
try {

View File

@@ -1,249 +0,0 @@
import { openDB } from 'idb';
import path from 'utils/common/path';
const DB_NAME = 'bruno-parsed-file-cache';
const STORE_NAME = 'parsedFiles';
const DB_VERSION = 1;
const CACHE_VERSION = '1.0.0';
const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
let dbPromise = null;
const getDB = () => {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
store.createIndex('collectionPath', 'collectionPath');
store.createIndex('parsedAt', 'parsedAt');
}
}
}).catch((err) => {
dbPromise = null;
throw err;
});
}
return dbPromise;
};
const generateKey = (collectionPath, filePath) => {
return `${collectionPath}${filePath}`;
};
export const parsedFileCacheStore = {
async getEntry(collectionPath, filePath) {
try {
const db = await getDB();
const key = generateKey(collectionPath, filePath);
const entry = await db.get(STORE_NAME, key);
if (entry && typeof entry.mtimeMs === 'number' && entry.parsedData) {
return {
mtimeMs: entry.mtimeMs,
parsedData: entry.parsedData
};
}
} catch (error) {
console.error('ParsedFileCacheStore: Error reading cache entry:', error);
}
return null;
},
async setEntry(collectionPath, filePath, entry, retryAfterEviction = true) {
try {
const db = await getDB();
const key = generateKey(collectionPath, filePath);
const cacheEntry = {
key,
collectionPath,
filePath,
mtimeMs: entry.mtimeMs,
parsedData: entry.parsedData,
parsedAt: Date.now()
};
await db.put(STORE_NAME, cacheEntry);
} catch (error) {
// Handle QuotaExceededError by evicting old entries and retrying
const isQuotaError
= error.name === 'QuotaExceededError'
|| error.code === 22 // Legacy Safari
|| (error.code === 1014 && error.name === 'NS_ERROR_DOM_QUOTA_REACHED'); // Firefox
if (isQuotaError && retryAfterEviction) {
console.warn('ParsedFileCacheStore: Quota exceeded, evicting old entries...');
const evicted = await this.evictLRU();
if (evicted > 0) {
// Retry the write after eviction
return this.setEntry(collectionPath, filePath, entry, false);
}
console.warn('ParsedFileCacheStore: No entries to evict, cache write skipped');
} else {
console.error('ParsedFileCacheStore: Error writing cache entry:', error);
}
}
},
async invalidate(collectionPath, filePath) {
try {
const db = await getDB();
const key = generateKey(collectionPath, filePath);
await db.delete(STORE_NAME, key);
} catch (error) {
console.error('ParsedFileCacheStore: Error invalidating cache entry:', error);
}
},
async invalidateCollection(collectionPath) {
try {
const db = await getDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const index = tx.store.index('collectionPath');
let cursor = await index.openCursor(IDBKeyRange.only(collectionPath));
while (cursor) {
await cursor.delete();
cursor = await cursor.continue();
}
await tx.done;
} catch (error) {
console.error('ParsedFileCacheStore: Error invalidating collection cache:', error);
}
},
async invalidateDirectory(collectionPath, dirPath) {
try {
const db = await getDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const index = tx.store.index('collectionPath');
const normalizedDirPath = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`;
let cursor = await index.openCursor(IDBKeyRange.only(collectionPath));
while (cursor) {
if (cursor.value.filePath.startsWith(normalizedDirPath)) {
await cursor.delete();
}
cursor = await cursor.continue();
}
await tx.done;
} catch (error) {
console.error('ParsedFileCacheStore: Error invalidating directory cache:', error);
}
},
async moveEntry(collectionPath, oldFilePath, newFilePath) {
try {
const entry = await this.getEntry(collectionPath, oldFilePath);
if (entry) {
await this.invalidate(collectionPath, oldFilePath);
await this.setEntry(collectionPath, newFilePath, {
mtimeMs: entry.mtimeMs,
parsedData: entry.parsedData
});
}
} catch (error) {
console.error('ParsedFileCacheStore: Error moving cache entry:', error);
}
},
async prune(maxAgeMs = DEFAULT_MAX_AGE_MS) {
try {
const db = await getDB();
const cutoff = Date.now() - maxAgeMs;
const tx = db.transaction(STORE_NAME, 'readwrite');
const index = tx.store.index('parsedAt');
let cursor = await index.openCursor(IDBKeyRange.upperBound(cutoff));
while (cursor) {
await cursor.delete();
cursor = await cursor.continue();
}
await tx.done;
} catch (error) {
console.error('ParsedFileCacheStore: Error pruning cache:', error);
}
},
/**
* Evict least recently used entries when quota is exceeded.
* Removes approximately 20% of the oldest entries to free up space.
* @returns {Promise<number>} Number of entries evicted
*/
async evictLRU(percentageToEvict = 0.2) {
try {
const db = await getDB();
const totalCount = await db.count(STORE_NAME);
if (totalCount === 0) {
return 0;
}
const countToEvict = Math.max(1, Math.floor(totalCount * percentageToEvict));
const tx = db.transaction(STORE_NAME, 'readwrite');
const index = tx.store.index('parsedAt');
let cursor = await index.openCursor();
let evicted = 0;
while (cursor && evicted < countToEvict) {
await cursor.delete();
evicted++;
cursor = await cursor.continue();
}
await tx.done;
console.log(`ParsedFileCacheStore: Evicted ${evicted} LRU entries to free up space`);
return evicted;
} catch (error) {
console.error('ParsedFileCacheStore: Error during LRU eviction:', error);
return 0;
}
},
async clear() {
try {
const db = await getDB();
await db.clear(STORE_NAME);
} catch (error) {
console.error('ParsedFileCacheStore: Error clearing cache:', error);
}
},
async getStats() {
try {
const db = await getDB();
// Use count() for O(1) total files count
const totalFiles = await db.count(STORE_NAME);
// Count unique collections using index with unique cursor
const tx = db.transaction(STORE_NAME, 'readonly');
const index = tx.store.index('collectionPath');
let totalCollections = 0;
// Use openKeyCursor with 'nextunique' to count unique collection paths
let cursor = await index.openKeyCursor(null, 'nextunique');
while (cursor) {
totalCollections++;
cursor = await cursor.continue();
}
return {
version: CACHE_VERSION,
totalCollections,
totalFiles
};
} catch (error) {
console.error('ParsedFileCacheStore: Error getting stats:', error);
return {
version: CACHE_VERSION,
totalCollections: 0,
totalFiles: 0,
error: error.message
};
}
}
};
export default parsedFileCacheStore;

View File

@@ -3,6 +3,9 @@ import { mockDataFunctions } from '@usebruno/common';
const CodeMirror = require('codemirror');
// Static API hints - Bruno JavaScript API (subgrouped by category)
// TODO: Restore the commented-out APIs once the UI update fixes are live.
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
const STATIC_API_HINTS = {
req: [
'req',
@@ -67,11 +70,11 @@ const STATIC_API_HINTS = {
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setCollectionVar(key, value)',
// 'bru.setCollectionVar(key, value)',
'bru.hasCollectionVar(key)',
'bru.deleteCollectionVar(key)',
'bru.deleteAllCollectionVars()',
'bru.getAllCollectionVars()',
// 'bru.deleteCollectionVar(key)',
// 'bru.deleteAllCollectionVars()',
// 'bru.getAllCollectionVars()',
'bru.setEnvVar(key, value)',
'bru.setEnvVar(key, value, options)',
'bru.deleteEnvVar(key)',
@@ -96,9 +99,9 @@ const STATIC_API_HINTS = {
'bru.getOauth2CredentialVar(key)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.deleteGlobalEnvVar(key)',
// 'bru.deleteGlobalEnvVar(key)',
'bru.getAllGlobalEnvVars()',
'bru.deleteAllGlobalEnvVars()',
// 'bru.deleteAllGlobalEnvVars()',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',

View File

@@ -225,6 +225,11 @@ const builder = async (yargs) => {
description: 'Disable all proxy settings (both collection-defined and system proxies)',
default: false
})
.option('cache-ssl-session', {
type: 'boolean',
description: 'Enable SSL session caching — reuses TLS sessions across requests for faster handshakes',
default: false
})
.option('delay', {
type: 'number',
description: 'Delay between each requests (in miliseconds)'
@@ -330,6 +335,7 @@ const handler = async function (argv) {
reporterSkipBody,
clientCertConfig,
noproxy,
cacheSslSession,
delay,
tags: includeTags,
excludeTags,
@@ -531,6 +537,9 @@ const handler = async function (argv) {
if (noproxy) {
options['noproxy'] = true;
}
if (cacheSslSession) {
options['cacheSslSession'] = true;
}
if (verbose) {
options['verbose'] = true;
}

View File

@@ -9,7 +9,8 @@ const { interpolateString, interpolateObject } = require('./interpolate-string')
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
const https = require('https');
const https = require('node:https');
const http = require('node:http');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
@@ -22,7 +23,7 @@ const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
@@ -203,7 +204,8 @@ const runSingleRequest = async function (
shouldVerifyTls: !get(options, 'insecure', false),
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
cacheSslSession: get(options, 'cacheSslSession', false)
},
clientCertificates: rawClientCertificates ? interpolateObject(rawClientCertificates, sendRequestInterpolationOptions) : undefined,
collectionLevelProxy: transformProxyConfig(interpolateObject(rawProxyConfig, sendRequestInterpolationOptions)),
@@ -347,6 +349,7 @@ const runSingleRequest = async function (
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const cachedSystemProxy = get(options, 'cachedSystemProxy', null);
const disableCache = !get(options, 'cacheSslSession', false);
const httpsAgentRequestFields = {};
if (insecure) {
@@ -426,6 +429,18 @@ const runSingleRequest = async function (
}
// else: collection proxy is disabled, proxyMode stays 'off'
// Prepare TLS options for agent caching
const tlsOptions = {
...httpsAgentRequestFields
};
// HTTP agent options — separate from tlsOptions to avoid leaking TLS fields
const httpAgentOptions = { keepAlive: true };
const parsedRequestUrl = new URL(request.url);
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
const hostname = parsedRequestUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
@@ -444,35 +459,37 @@ const runSingleRequest = async function (
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
// (e.g., ca certs) even for plain HTTP requests
const isHttpsProxy = proxyProtocol === 'https';
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
// Only set the agent needed for the request protocol
if (socksEnabled) {
request.httpsAgent = new SocksProxyAgent(
proxyUri,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
request.httpAgent = new SocksProxyAgent(proxyUri);
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
request.httpsAgent = new PatchedHttpsProxyAgent(
proxyUri,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
request.httpAgent = new HttpProxyAgent(proxyUri);
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
const parsedUrl = new URL(request.url);
const isHttpsRequest = parsedUrl.protocol === 'https:';
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
new URL(http_proxy);
request.httpAgent = new HttpProxyAgent(http_proxy);
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
@@ -480,30 +497,21 @@ const runSingleRequest = async function (
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
request.httpsAgent = new PatchedHttpsProxyAgent(https_proxy,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined);
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} catch (error) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
} catch (error) {}
}
if (!request.httpAgent && !request.httpsAgent) {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
// set cookies if enabled
@@ -610,12 +618,13 @@ const runSingleRequest = async function (
let token;
if (oauth2RequestUrl) {
const tlsOptions = {
const oauth2ConfigOptions = {
noproxy: options.noproxy,
shouldVerifyTls: !insecure,
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
cacheSslSession: !disableCache
};
const clientCertificates = get(brunoConfig, 'clientCertificates');
@@ -627,7 +636,7 @@ const runSingleRequest = async function (
const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({
requestUrl: oauth2RequestUrl,
collectionPath,
options: tlsOptions,
options: oauth2ConfigOptions,
clientCertificates: interpolatedClientCertificates,
collectionLevelProxy: interpolatedProxyConfig,
systemProxyConfig

View File

@@ -63,9 +63,17 @@ const shouldUseProxy = (url, proxyBypass) => {
};
/**
* Patched version of HttpsProxyAgent to get around a bug that ignores
* options like ca and rejectUnauthorized when upgrading the socket to TLS:
* https://github.com/TooTallNate/proxy-agents/issues/194
* Options that should be forwarded from the constructor to the target TLS upgrade.
*/
const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
/**
* Patched version of HttpsProxyAgent that correctly handles TLS options for
* both the proxy connection and the target server connection.
*
* The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
* ignores constructor options when upgrading the tunneled socket to TLS for the
* target server. This patch forwards the relevant TLS options to the target upgrade.
*/
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
constructor(proxy, opts) {
@@ -74,8 +82,17 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
async connect(req, opts) {
const combinedOpts = { ...this.constructorOpts, ...opts };
return super.connect(req, combinedOpts);
const targetOpts = { ...opts };
if (this.constructorOpts) {
for (const key of TARGET_TLS_OPTIONS) {
if (key in this.constructorOpts) {
targetOpts[key] = this.constructorOpts[key];
}
}
}
return super.connect(req, targetOpts);
}
}

View File

@@ -1,5 +1,8 @@
import translateCode from '../utils/postman-to-bruno-translator';
// TODO: Restore the commented-out translations once the UI update fixes are live.
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
const replacements = {
'pm\\.environment\\.get\\(': 'bru.getEnvVar(',
'pm\\.environment\\.set\\(': 'bru.setEnvVar(',
@@ -7,11 +10,11 @@ const replacements = {
'pm\\.variables\\.set\\(': 'bru.setVar(',
'pm\\.variables\\.replaceIn\\(': 'bru.interpolate(',
'pm\\.collectionVariables\\.get\\(': 'bru.getCollectionVar(',
'pm\\.collectionVariables\\.set\\(': 'bru.setCollectionVar(',
// 'pm\\.collectionVariables\\.set\\(': 'bru.setCollectionVar(',
'pm\\.collectionVariables\\.has\\(': 'bru.hasCollectionVar(',
'pm\\.collectionVariables\\.unset\\(': 'bru.deleteCollectionVar(',
'pm\\.collectionVariables\\.clear\\(': 'bru.deleteAllCollectionVars(',
'pm\\.collectionVariables\\.toObject\\(': 'bru.getAllCollectionVars(',
// 'pm\\.collectionVariables\\.unset\\(': 'bru.deleteCollectionVar(',
// 'pm\\.collectionVariables\\.clear\\(': 'bru.deleteAllCollectionVars(',
// 'pm\\.collectionVariables\\.toObject\\(': 'bru.getAllCollectionVars(',
'pm\\.setNextRequest\\(': 'bru.setNextRequest(',
'pm\\.test\\(': 'test(',
'pm.response.to.have\\.status\\(': 'expect(res.getStatus()).to.equal(',
@@ -25,9 +28,9 @@ const replacements = {
'pm\\.response\\.responseTime': 'res.getResponseTime()',
'pm\\.globals\\.set\\(': 'bru.setGlobalEnvVar(',
'pm\\.globals\\.get\\(': 'bru.getGlobalEnvVar(',
'pm\\.globals\\.unset\\(': 'bru.deleteGlobalEnvVar(',
// 'pm\\.globals\\.unset\\(': 'bru.deleteGlobalEnvVar(',
'pm\\.globals\\.toObject\\(': 'bru.getAllGlobalEnvVars(',
'pm\\.globals\\.clear\\(': 'bru.deleteAllGlobalEnvVars(',
// 'pm\\.globals\\.clear\\(': 'bru.deleteAllGlobalEnvVars(',
'pm\\.environment\\.toObject\\(': 'bru.getAllEnvVars(',
'pm\\.environment\\.clear\\(': 'bru.deleteAllEnvVars(',
'pm\\.variables\\.toObject\\(': 'bru.getAllVars(',

View File

@@ -13,13 +13,16 @@ const j = require('jscodeshift');
* Simple 1:1 translations from Bruno helpers to Postman helpers.
* These are direct member expression replacements.
*/
// TODO: Restore the commented-out translations once the UI update fixes are live.
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
const simpleTranslations = {
// Global variables
'bru.getGlobalEnvVar': 'pm.globals.get',
'bru.setGlobalEnvVar': 'pm.globals.set',
'bru.deleteGlobalEnvVar': 'pm.globals.unset',
// 'bru.deleteGlobalEnvVar': 'pm.globals.unset',
'bru.getAllGlobalEnvVars': 'pm.globals.toObject',
'bru.deleteAllGlobalEnvVars': 'pm.globals.clear',
// 'bru.deleteAllGlobalEnvVars': 'pm.globals.clear',
// Environment variables
'bru.getEnvVar': 'pm.environment.get',
@@ -40,11 +43,11 @@ const simpleTranslations = {
// Collection variables
'bru.getCollectionVar': 'pm.collectionVariables.get',
'bru.setCollectionVar': 'pm.collectionVariables.set',
// 'bru.setCollectionVar': 'pm.collectionVariables.set',
'bru.hasCollectionVar': 'pm.collectionVariables.has',
'bru.deleteCollectionVar': 'pm.collectionVariables.unset',
'bru.getAllCollectionVars': 'pm.collectionVariables.toObject',
'bru.deleteAllCollectionVars': 'pm.collectionVariables.clear',
// 'bru.deleteCollectionVar': 'pm.collectionVariables.unset',
// 'bru.getAllCollectionVars': 'pm.collectionVariables.toObject',
// 'bru.deleteAllCollectionVars': 'pm.collectionVariables.clear',
// Folder variables
'bru.getFolderVar': 'pm.variables.get',

View File

@@ -4,14 +4,17 @@ const j = require('jscodeshift');
const cloneDeep = require('lodash/cloneDeep');
// Simple 1:1 translations for straightforward replacements
// TODO: Restore the commented-out translations once the UI update fixes are live.
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
const simpleTranslations = {
// Global Variables
'pm.globals.get': 'bru.getGlobalEnvVar',
'pm.globals.set': 'bru.setGlobalEnvVar',
'pm.globals.replaceIn': 'bru.interpolate',
'pm.globals.unset': 'bru.deleteGlobalEnvVar',
// 'pm.globals.unset': 'bru.deleteGlobalEnvVar',
'pm.globals.toObject': 'bru.getAllGlobalEnvVars',
'pm.globals.clear': 'bru.deleteAllGlobalEnvVars',
// 'pm.globals.clear': 'bru.deleteAllGlobalEnvVars',
// Environment variables
'pm.environment.get': 'bru.getEnvVar',
@@ -30,12 +33,12 @@ const simpleTranslations = {
'pm.variables.replaceIn': 'bru.interpolate',
// Collection variables
'pm.collectionVariables.get': 'bru.getCollectionVar',
'pm.collectionVariables.set': 'bru.setCollectionVar',
// 'pm.collectionVariables.set': 'bru.setCollectionVar',
'pm.collectionVariables.has': 'bru.hasCollectionVar',
'pm.collectionVariables.unset': 'bru.deleteCollectionVar',
// 'pm.collectionVariables.unset': 'bru.deleteCollectionVar',
'pm.collectionVariables.replaceIn': 'bru.interpolate',
'pm.collectionVariables.clear': 'bru.deleteAllCollectionVars',
'pm.collectionVariables.toObject': 'bru.getAllCollectionVars',
// 'pm.collectionVariables.clear': 'bru.deleteAllCollectionVars',
// 'pm.collectionVariables.toObject': 'bru.getAllCollectionVars',
// Request flow control
'pm.setNextRequest': 'bru.setNextRequest',

View File

@@ -58,7 +58,8 @@ describe('Bruno to Postman Variables Translation', () => {
expect(translatedCode).toBe('pm.collectionVariables.get("baseUrl");');
});
it('should translate bru.setCollectionVar', () => {
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should translate bru.setCollectionVar', () => {
const code = 'bru.setCollectionVar("baseUrl", "https://api.example.com");';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toBe('pm.collectionVariables.set("baseUrl", "https://api.example.com");');
@@ -70,19 +71,22 @@ describe('Bruno to Postman Variables Translation', () => {
expect(translatedCode).toBe('pm.collectionVariables.has("baseUrl");');
});
it('should translate bru.deleteCollectionVar', () => {
// TODO: Restore once UI update fixes are live for deleteCollectionVar
it.skip('should translate bru.deleteCollectionVar', () => {
const code = 'bru.deleteCollectionVar("baseUrl");';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toBe('pm.collectionVariables.unset("baseUrl");');
});
it('should translate bru.getAllCollectionVars', () => {
// TODO: Restore once UI update fixes are live for getAllCollectionVars
it.skip('should translate bru.getAllCollectionVars', () => {
const code = 'const vars = bru.getAllCollectionVars();';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toBe('const vars = pm.collectionVariables.toObject();');
});
it('should translate bru.deleteAllCollectionVars', () => {
// TODO: Restore once UI update fixes are live for deleteAllCollectionVars
it.skip('should translate bru.deleteAllCollectionVars', () => {
const code = 'bru.deleteAllCollectionVars();';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toBe('pm.collectionVariables.clear();');

View File

@@ -7,11 +7,15 @@ describe('postmanTranslations - comment handling', () => {
const data = pm.environment.get('key');
pm.collectionVariables.set('key', data);
`;
const expectedOutput = `
console.log('This script does not contain pm commands.');
const data = bru.getEnvVar('key');
bru.setCollectionVar('key', data);
`;
const result = postmanTranslation(inputScript);
expect(result).toContain('console.log(\'This script does not contain pm commands.\');');
expect(result).toContain('const data = bru.getEnvVar(\'key\');');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
test.skip('should translate pm.collectionVariables.set to bru.setCollectionVar', () => {
const inputScript = 'pm.collectionVariables.set(\'key\', data);';
const expectedOutput = 'bru.setCollectionVar(\'key\', data);';
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@@ -1,25 +1,42 @@
import postmanTranslation from '../../../src/postman/postman-translations';
describe('postmanTranslations - variables commands', () => {
test('should translate variable commands correctly', () => {
test('should translate environment variable commands', () => {
const inputScript = `
pm.environment.get('key');
pm.environment.set('key', 'value');
`;
const result = postmanTranslation(inputScript);
expect(result).toContain('bru.getEnvVar(\'key\')');
expect(result).toContain('bru.setEnvVar(\'key\', \'value\')');
});
test('should translate runtime variable commands', () => {
const inputScript = `
pm.variables.get('key');
pm.variables.set('key', 'value');
pm.collectionVariables.get('key');
pm.collectionVariables.set('key', 'value');
pm.expect(pm.environment.has('key')).to.be.true;
`;
const expectedOutput = `
bru.getEnvVar('key');
bru.setEnvVar('key', 'value');
bru.getVar('key');
bru.setVar('key', 'value');
bru.getCollectionVar('key');
bru.setCollectionVar('key', 'value');
expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
const result = postmanTranslation(inputScript);
expect(result).toContain('bru.getVar(\'key\')');
expect(result).toContain('bru.setVar(\'key\', \'value\')');
});
test('should translate pm.collectionVariables.get', () => {
const inputScript = 'pm.collectionVariables.get(\'key\');';
const result = postmanTranslation(inputScript);
expect(result).toContain('bru.getCollectionVar(\'key\')');
});
test('should translate pm.expect with pm.environment.has', () => {
const inputScript = 'pm.expect(pm.environment.has(\'key\')).to.be.true;';
const result = postmanTranslation(inputScript);
expect(result).toContain('bru.getEnvVar(\'key\') !== undefined && bru.getEnvVar(\'key\') !== null');
expect(result).toContain('.to.be.true');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
test.skip('should translate pm.collectionVariables.set to bru.setCollectionVar', () => {
const inputScript = 'pm.collectionVariables.set(\'key\', \'value\');';
expect(postmanTranslation(inputScript)).toBe('bru.setCollectionVar(\'key\', \'value\');');
});
});

View File

@@ -93,13 +93,18 @@ describe('Combined API Features Translation', () => {
expect(translatedCode).not.toContain('pm.test("Auth flow works", function() {');
expect(translatedCode).not.toContain('pm.expect(response.authenticated).to.be.true;');
expect(translatedCode).not.toContain('pm.environment.set("userId", response.user.id);');
expect(translatedCode).not.toContain('pm.collectionVariables.set("sessionId", response.session.id);');
expect(translatedCode).toContain('const token = bru.getEnvVar("authToken");');
expect(translatedCode).toContain('test("Auth flow works", function() {');
expect(translatedCode).toContain('const response = res.getBody();');
expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
expect(translatedCode).toContain('bru.setCollectionVar("sessionId", response.session.id);');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should translate pm.collectionVariables.set in a combined code block', () => {
const code = 'pm.collectionVariables.set("sessionId", response.session.id);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setCollectionVar("sessionId", response.session.id);');
});
// Nested expressions
@@ -109,7 +114,8 @@ describe('Combined API Features Translation', () => {
expect(translatedCode).toBe('bru.setEnvVar("computed", bru.getVar("base") + "-suffix");');
});
it('should handle more complex nested expressions', () => {
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should handle more complex nested expressions', () => {
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setCollectionVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
@@ -347,14 +353,19 @@ describe('Combined API Features Translation', () => {
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
function processResponse() {
test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });
bru.setEnvVar("userId", res.getBody().userId);
bru.setVar("token", res.getBody().token);
bru.setCollectionVar("sessionId", res.getBody().sessionId);
}
`);
expect(translatedCode).toContain('test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });');
expect(translatedCode).toContain('bru.setEnvVar("userId", res.getBody().userId);');
expect(translatedCode).toContain('bru.setVar("token", res.getBody().token);');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should translate pm.collectionVariables alias set inside functions', () => {
const code = `
const tempCollVars = pm.collectionVariables;
tempCollVars.set("sessionId", "value");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setCollectionVar("sessionId", "value");');
});
it('should nested pm commands', () => {

View File

@@ -34,18 +34,23 @@ describe('Multiline Syntax Handling', () => {
`);
});
it('should handle multiline collection variable syntax', () => {
it('should handle multiline collection variable get syntax', () => {
const code = `
const apiKey = pm.collectionVariables
.get("apiKey");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const apiKey = bru.getCollectionVar("apiKey")');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should handle multiline collection variable set syntax', () => {
const code = `
pm.collectionVariables
.set("lastRun", new Date().toISOString());
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const apiKey = bru.getCollectionVar("apiKey");
bru.setCollectionVar("lastRun", new Date().toISOString());
`);
expect(translatedCode).toContain('bru.setCollectionVar("lastRun", new Date().toISOString())');
});
it('should handle complex environment.has transformation with multiline syntax', () => {
@@ -193,7 +198,7 @@ describe('Multiline Syntax Handling', () => {
it('should handle a comprehensive script with various multiline formats', () => {
const code = `
// This comprehensive script tests different multiline styles and whitespace variations
// Environment variables with different formatting styles
const baseUrl = pm.environment.get("baseUrl");
const apiKey = pm
@@ -201,39 +206,39 @@ describe('Multiline Syntax Handling', () => {
.get("apiKey");
const userId = pm.environment
.get("userId");
// Mix of variable styles
pm.variables.set("testId", "test-" + Date.now());
pm
.variables
.set("timestamp", new Date().toISOString());
// Collection variables with inconsistent spacing
pm.collectionVariables
.set("lastRun", new Date());
// Complex conditionals with multiline expressions
if (pm
.environment
.has("apiKey") &&
.has("apiKey") &&
pm.variables.has("testId")) {
// Testing response with mixed syntax styles
pm.test("Response validation", function() {
// Normal style
pm.response.to.have.status(200);
// Multiline with different indentation
pm
.response
.to
.have
.header("content-type");
pm.response
.to.have
.jsonBody("success", true);
// Extreme indentation
pm
.response
@@ -242,7 +247,7 @@ describe('Multiline Syntax Handling', () => {
.have
.jsonBody("error");
});
// Flow control with mixed styles
if (pm.response.code === 401) {
pm.execution.setNextRequest(null);
@@ -264,9 +269,6 @@ describe('Multiline Syntax Handling', () => {
expect(translatedCode).toContain('bru.setVar("testId", "test-" + Date.now())');
expect(translatedCode).toContain('bru.setVar("timestamp", new Date().toISOString())');
// Check collection variables
expect(translatedCode).toContain('bru.setCollectionVar("lastRun", new Date())');
// Check complex conditionals
expect(translatedCode).toContain('if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null &&');
expect(translatedCode).toContain('bru.hasVar("testId"))');
@@ -280,4 +282,14 @@ describe('Multiline Syntax Handling', () => {
expect(translatedCode).toContain('bru.runner.stopExecution()');
expect(translatedCode).toContain('bru.runner.setNextRequest("Next API Call")');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should translate multiline pm.collectionVariables.set in comprehensive script', () => {
const code = `
pm.collectionVariables
.set("lastRun", new Date());
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setCollectionVar("lastRun", new Date())');
});
});

View File

@@ -281,6 +281,12 @@ describe('Response Translation', () => {
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const items = res.getBody().items;');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should translate pm.collectionVariables.set with array access pattern', () => {
const code = 'pm.collectionVariables.set("item_" + i, items[i].id);';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setCollectionVar("item_" + i, items[i].id);');
});

View File

@@ -67,6 +67,12 @@ describe('Testing Framework Translation', () => {
expect(translatedCode).toContain('const response = res.getBody();');
expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
});
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should translate pm.collectionVariables.set inside test functions', () => {
const code = 'pm.collectionVariables.set("sessionId", response.session.id);';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setCollectionVar("sessionId", response.session.id);');
});

View File

@@ -71,7 +71,8 @@ describe('Variables Translation', () => {
expect(translatedCode).toBe('bru.getCollectionVar("apiUrl");');
});
it('should translate pm.collectionVariables.set', () => {
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should translate pm.collectionVariables.set', () => {
const code = 'pm.collectionVariables.set("token", jsonData.token);';
const translatedCode = translateCode(code);
@@ -85,7 +86,8 @@ describe('Variables Translation', () => {
expect(translatedCode).toBe('bru.hasCollectionVar("authToken");');
});
it('should translate pm.collectionVariables.unset', () => {
// TODO: Restore once UI update fixes are live for deleteCollectionVar
it.skip('should translate pm.collectionVariables.unset', () => {
const code = 'pm.collectionVariables.unset("tempVar");';
const translatedCode = translateCode(code);
@@ -124,7 +126,8 @@ describe('Variables Translation', () => {
});
// Alias tests for collection variables
it('should handle collection variables aliases', () => {
// TODO: Restore once UI update fixes are live for setCollectionVar/deleteCollectionVar
it.skip('should handle collection variables aliases', () => {
const code = `
const collVars = pm.collectionVariables;
const has = collVars.has("test");
@@ -180,7 +183,8 @@ describe('Variables Translation', () => {
expect(translatedCode).toContain('bru.setVar("requestTime", new Date().toISOString());');
});
it('should handle all collection variable methods together', () => {
// TODO: Restore once UI update fixes are live for setCollectionVar/deleteCollectionVar
it.skip('should handle all collection variable methods together', () => {
const code = `
// All collection variable methods
const hasApiUrl = pm.collectionVariables.has("apiUrl");
@@ -198,7 +202,8 @@ describe('Variables Translation', () => {
expect(translatedCode).toContain('bru.deleteCollectionVar("tempVar");');
});
it('should handle more complex nested expressions with variables', () => {
// TODO: Restore once UI update fixes are live for setCollectionVar
it.skip('should handle more complex nested expressions with variables', () => {
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
const translatedCode = translateCode(code);

View File

@@ -1,201 +0,0 @@
/**
* CollectionTreeBatcher - Batches IPC events to reduce Redux dispatch overhead.
*
* Instead of sending individual 'main:collection-tree-updated' events for each file,
* this batcher collects events and sends them in batches, reducing the number of
* Redux updates and improving UI performance during collection mounting.
*
* Flush triggers:
* - Time-based: Every DISPATCH_INTERVAL_MS (200ms)
* - Size-based: When batch reaches MAX_BATCH_SIZE (300 items)
* - Manual: Call flush() directly (e.g., on watcher 'ready' event)
*/
const DISPATCH_INTERVAL_MS = 200;
const MAX_BATCH_SIZE = 200;
class CollectionTreeBatcher {
constructor(win, collectionUid) {
this.win = win;
this.queue = [];
this.timer = null;
this.isDestroyed = false;
// Bind methods
// We need to bind the methods because these are being called as callbacks to
// chokidar's add, addDir, change, unlink, unlinkDir events
this.add = this.add.bind(this);
this.flush = this.flush.bind(this);
this._scheduleFlush = this._scheduleFlush.bind(this);
}
/**
* Check if the window is still valid for sending events
*/
_isWindowValid() {
return this.win && !this.win.isDestroyed() && !this.isDestroyed;
}
/**
* Schedule a flush after the dispatch interval
*/
_scheduleFlush() {
if (this.timer || !this._isWindowValid()) {
return;
}
this.timer = setTimeout(() => {
this.timer = null;
this.flush();
}, DISPATCH_INTERVAL_MS);
}
/**
* Add an event to the batch queue
* @param {string} eventType - The event type ('addFile', 'addDir', 'change', 'unlink', 'unlinkDir')
* @param {object} payload - The event payload
*/
add(eventType, payload) {
if (!this._isWindowValid()) {
return;
}
this.queue.push({
eventType,
payload
});
// Flush immediately if batch is full
if (this.queue.length >= MAX_BATCH_SIZE) {
this.flush();
} else {
this._scheduleFlush();
}
}
/**
* Flush the current batch to the renderer
*/
flush() {
// Clear any pending timer
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.queue.length === 0 || !this._isWindowValid()) {
return;
}
// Take all items from the queue
// This is a copy-type operation to avoid mutating the original
// Splice returns the deleted items
const batch = this.queue.splice(0);
try {
// Send the batch to the renderer
this.win.webContents.send('main:collection-tree-batch-updated', batch);
} catch (error) {
console.error('CollectionTreeBatcher: Error sending batch:', error);
this.queue.push(...batch);
}
}
/**
* Get the current queue size
* @returns {number} - The number of items in the queue
*/
size() {
return this.queue.length;
}
/**
* Clear the queue without sending
*/
clear() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.queue = [];
}
/**
* Mark this batcher as destroyed (e.g., when window closes)
*/
destroy() {
this.isDestroyed = true;
this.clear();
this.win = null;
}
}
// Store for managing batchers per collection
const batchers = new Map();
/**
* Get the batcher key for a window and collection UID
* @param {BrowserWindow} win - The Electron BrowserWindow
* @param {string} collectionUid - The collection UID
* @returns {string} - The batcher key
*/
const getBatcherKey = (win, collectionUid) => {
return `${win.id}-${collectionUid}`;
};
/**
* Get or create a CollectionTreeBatcher for a window
* @param {BrowserWindow} win - The Electron BrowserWindow
* @param {string} collectionUid - The collection UID
* @returns {CollectionTreeBatcher} - The batcher instance
*/
const getBatcher = (win, collectionUid) => {
const batcherKey = getBatcherKey(win, collectionUid);
if (!batchers.has(batcherKey)) {
const batcher = new CollectionTreeBatcher(win, collectionUid);
// Clean up when window is closed
win.once('closed', () => {
const b = batchers.get(batcherKey);
if (b) {
b.destroy();
batchers.delete(batcherKey);
}
});
batchers.set(batcherKey, batcher);
}
return batchers.get(batcherKey);
};
/**
* Remove a batcher for a window
* @param {BrowserWindow} win - The Electron BrowserWindow
* @param {string} collectionUid - The collection UID
*/
const removeBatcher = (win, collectionUid) => {
const batcherKey = getBatcherKey(win, collectionUid);
const batcher = batchers.get(batcherKey);
if (batcher) {
batcher.destroy();
batchers.delete(batcherKey);
}
};
// Export with backward-compatible aliases
module.exports = {
CollectionTreeBatcher,
getBatcher,
removeBatcher,
// Backward-compatible aliases
BatchAggregator: CollectionTreeBatcher,
getAggregator: getBatcher,
removeAggregator: removeBatcher,
constants: {
MAX_BATCH_SIZE,
DISPATCH_INTERVAL_MS
}
};

View File

@@ -1,6 +1,5 @@
const _ = require('lodash');
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const chokidar = require('chokidar');
const {
@@ -27,8 +26,6 @@ const UiStateSnapshot = require('../store/ui-state-snapshot');
const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
const { parsedFileCacheStore } = require('../store/parsed-file-cache-idb');
const { getBatcher } = require('./collection-tree-batcher');
const dotEnvWatcher = require('./dotenv-watcher');
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
@@ -226,8 +223,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
return addEnvironmentFile(win, pathname, collectionUid, collectionPath);
}
const batcher = getBatcher(win, collectionUid);
if (isCollectionRootFile(pathname, collectionPath)) {
const format = getCollectionFormat(collectionPath);
const file = {
@@ -294,8 +289,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
file.data = await parseFolder(content, { format });
hydrateCollectionRootWithUuid(file.data);
// win.webContents.send('main:collection-tree-updated', 'addFile', file);
batcher.add('addFile', file);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
return;
} catch (err) {
console.error(err);
@@ -315,69 +309,61 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
};
try {
const fileStats = await fsPromises.stat(pathname);
const fileStats = fs.statSync(pathname);
let content = fs.readFileSync(pathname, 'utf8');
const cachedEntry = await parsedFileCacheStore.getEntry(collectionPath, pathname);
if (cachedEntry && cachedEntry.mtimeMs === fileStats.mtimeMs) {
// Cache hit
file.data = cachedEntry.parsedData;
file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
batcher.add('addFile', file);
watcher.markFileAsProcessed(win, collectionUid, pathname);
return;
}
// Cache miss
const content = await fsPromises.readFile(pathname, 'utf8');
if (!useWorkerThread) {
// If worker thread is not used, we can directly parse the file
if (!useWorkerThread) {
try {
file.data = await parseRequest(content, { format });
file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
batcher.add('addFile', file);
await parsedFileCacheStore.setEntry(collectionPath, pathname, {
mtimeMs: fileStats.mtimeMs,
parsedData: file.data
});
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} catch (error) {
console.error(error);
} finally {
watcher.markFileAsProcessed(win, collectionUid, pathname);
return;
}
return;
}
try {
// we need to send a partial file info to the UI
// so that the UI can display the file in the collection tree
file.data = {
name: path.basename(pathname),
type: 'http-request'
};
const metaJson = parseFileMeta(content, format);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
if (fileStats.size < MAX_FILE_SIZE) {
// This is to update the loading indicator in the UI
file.data = metaJson;
file.partial = false;
file.loading = true;
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
// This is to update the file info in the UI
file.data = await parseRequestViaWorker(content, {
format,
filename: pathname
});
file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
batcher.add('addFile', file);
await parsedFileCacheStore.setEntry(collectionPath, pathname, {
mtimeMs: fileStats.mtimeMs,
parsedData: file.data
});
} else {
const metaJson = parseFileMeta(content, format);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
batcher.add('addFile', file);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
}
watcher.markFileAsProcessed(win, collectionUid, pathname);
} catch (error) {
console.error(`Error processing file ${pathname}:`, error);
file.data = {
name: path.basename(pathname),
type: 'http-request'
@@ -387,8 +373,10 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
};
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
batcher.add('addFile', file);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} finally {
watcher.markFileAsProcessed(win, collectionUid, pathname);
}
}
@@ -408,16 +396,15 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const folderFilePath = path.join(pathname, `folder.${format}`);
try {
await fsPromises.access(folderFilePath);
const folderFileContent = await fsPromises.readFile(folderFilePath, 'utf8');
const folderData = await parseFolder(folderFileContent, { format });
name = folderData?.meta?.name || name;
seq = folderData?.meta?.seq;
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(`Error occurred while parsing folder.${format} file`);
console.error(error);
if (fs.existsSync(folderFilePath)) {
let folderFileContent = fs.readFileSync(folderFilePath, 'utf8');
let folderData = await parseFolder(folderFileContent, { format });
name = folderData?.meta?.name || name;
seq = folderData?.meta?.seq;
}
} catch (error) {
console.error(`Error occured while parsing folder.${format} file`);
console.error(error);
}
const directory = {
@@ -430,8 +417,7 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
}
};
const batcher = getBatcher(win, collectionUid);
batcher.add('addDir', directory);
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
};
const change = async (win, pathname, collectionUid, collectionPath) => {
@@ -538,9 +524,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
const format = getCollectionFormat(collectionPath);
if (hasRequestExtension(pathname, format)) {
// Invalidate cache for this file since it changed
await parsedFileCacheStore.invalidate(collectionPath, pathname);
try {
const file = {
meta: {
@@ -561,14 +544,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
// Update cache with new parsed data
await parsedFileCacheStore.setEntry(collectionPath, pathname, {
mtimeMs: fileStats.mtimeMs,
parsedData: file.data
});
// Change events are not batched - they need immediate feedback
win.webContents.send('main:collection-tree-updated', 'change', file);
} catch (err) {
console.error(err);
@@ -576,7 +551,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
}
};
const unlink = async (win, pathname, collectionUid, collectionPath) => {
const unlink = (win, pathname, collectionUid, collectionPath) => {
console.log(`watcher unlink: ${pathname}`);
if (isEnvironmentsFolder(pathname, collectionPath)) {
@@ -585,9 +560,6 @@ const unlink = async (win, pathname, collectionUid, collectionPath) => {
const format = getCollectionFormat(collectionPath);
if (hasRequestExtension(pathname, format)) {
// Invalidate cache for deleted file
await parsedFileCacheStore.invalidate(collectionPath, pathname);
const basename = path.basename(pathname);
const dirname = path.dirname(pathname);
@@ -613,8 +585,6 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
return;
}
await parsedFileCacheStore.invalidateDirectory(collectionPath, pathname);
const format = getCollectionFormat(collectionPath);
const folderFilePath = path.join(pathname, `folder.${format}`);
@@ -637,9 +607,7 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
};
const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
const batcher = getBatcher(win, collectionUid);
batcher.flush();
// Mark discovery as complete
watcher.completeCollectionDiscovery(win, collectionUid);
const UiStateSnapshotStore = new UiStateSnapshot();

View File

@@ -39,7 +39,6 @@ const registerNetworkIpc = require('./ipc/network');
const registerCollectionsIpc = require('./ipc/collection');
const registerFilesystemIpc = require('./ipc/filesystem');
const registerPreferencesIpc = require('./ipc/preferences');
const { parsedFileCacheStore } = require('./store/parsed-file-cache-idb');
const registerSystemMonitorIpc = require('./ipc/system-monitor');
const registerWorkspaceIpc = require('./ipc/workspace');
const registerApiSpecIpc = require('./ipc/apiSpec');
@@ -461,9 +460,6 @@ app.on('ready', async () => {
});
});
// Initialize the parsed file cache IPC handlers
parsedFileCacheStore.initialize(mainWindow);
// register all ipc handlers
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);

View File

@@ -8,6 +8,7 @@ const {
isFile,
isDirectory
} = require('../utils/filesystem');
const { findUniqueFolderName } = require('../utils/collection-import');
const registerFilesystemIpc = (mainWindow) => {
ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => {
@@ -47,6 +48,14 @@ const registerFilesystemIpc = (mainWindow) => {
ipcMain.handle('renderer:is-directory', async (_, pathname) => {
return isDirectory(pathname);
});
ipcMain.handle('renderer:find-unique-folder-name', async (_, baseName, location) => {
try {
return await findUniqueFolderName(baseName, location);
} catch (error) {
throw error;
}
});
};
module.exports = registerFilesystemIpc;

View File

@@ -194,7 +194,8 @@ const buildCertsAndProxyConfig = async ({
shouldVerifyTls: preferencesUtil.shouldVerifyTls(),
shouldUseCustomCaCertificate: preferencesUtil.shouldUseCustomCaCertificate(),
customCaCertificateFilePath: preferencesUtil.getCustomCaCertificateFilePath(),
shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates()
shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates(),
cacheSslSession: preferencesUtil.isSslSessionCachingEnabled()
};
// Get client certificates from bruno config and interpolate

View File

@@ -2,11 +2,11 @@ const { ipcMain, nativeTheme } = require('electron');
const { getPreferences, savePreferences } = require('../store/preferences');
const { getGitVersion } = require('../utils/git');
const { globalEnvironmentsStore } = require('../store/global-environments');
const { parsedFileCacheStore } = require('../store/parsed-file-cache-idb');
const { getCachedSystemProxy, fetchSystemProxy } = require('../store/system-proxy');
const { resolveDefaultLocation } = require('../utils/default-location');
const onboardUser = require('../app/onboarding');
const LastOpenedCollections = require('../store/last-opened-collections');
const { clearAgentCache } = require('@usebruno/requests');
const registerPreferencesIpc = (mainWindow) => {
const lastOpenedCollections = new LastOpenedCollections();
@@ -57,29 +57,18 @@ const registerPreferencesIpc = (mainWindow) => {
}
});
ipcMain.handle('renderer:clear-http-https-agent-cache', async () => {
try {
clearAgentCache();
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.on('renderer:theme-change', (event, theme) => {
nativeTheme.themeSource = theme;
});
ipcMain.handle('renderer:get-cache-stats', async () => {
try {
return await parsedFileCacheStore.getStats();
} catch (error) {
console.error('Error getting cache stats:', error);
return { error: error.message };
}
});
ipcMain.handle('renderer:purge-cache', async () => {
try {
await parsedFileCacheStore.clear();
return { success: true };
} catch (error) {
console.error('Error purging cache:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('renderer:get-system-proxy-variables', async () => {
return await getCachedSystemProxy();
});

View File

@@ -1,157 +0,0 @@
const { ipcMain } = require('electron');
const { v4: uuidv4 } = require('uuid');
// Pending requests waiting for renderer response
const pendingRequests = new Map();
// Timeout for IPC requests (5 seconds)
const REQUEST_TIMEOUT = 5000;
// Store reference to main window
let mainWindow = null;
// Initialize the IPC response handler
const initializeCacheIpc = (win) => {
mainWindow = win;
ipcMain.on('renderer:parsed-file-cache-response', (event, response) => {
const { requestId, success, data, error } = response;
const pending = pendingRequests.get(requestId);
if (pending) {
pendingRequests.delete(requestId);
clearTimeout(pending.timeout);
if (success) {
pending.resolve(data);
} else {
pending.reject(new Error(error || 'Unknown error'));
}
}
});
};
// Send a request to the renderer and wait for response
const sendCacheRequest = (operation, ...args) => {
return new Promise((resolve, reject) => {
if (!mainWindow || mainWindow.isDestroyed()) {
resolve(null);
return;
}
const requestId = uuidv4();
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
resolve(null);
}, REQUEST_TIMEOUT);
pendingRequests.set(requestId, { resolve, reject, timeout });
mainWindow.webContents.send('main:parsed-file-cache-request', operation, requestId, ...args);
});
};
class ParsedFileCacheStore {
constructor() {
this.initialized = false;
}
initialize(win) {
if (!this.initialized) {
initializeCacheIpc(win);
this.initialized = true;
}
}
async getEntry(collectionPath, filePath) {
try {
return await sendCacheRequest('getEntry', collectionPath, filePath);
} catch (error) {
console.error('ParsedFileCacheStore: Error reading cache entry:', error);
return null;
}
}
async setEntry(collectionPath, filePath, entry) {
try {
await sendCacheRequest('setEntry', collectionPath, filePath, entry);
} catch (error) {
console.error('ParsedFileCacheStore: Error writing cache entry:', error);
}
}
async invalidate(collectionPath, filePath) {
try {
await sendCacheRequest('invalidate', collectionPath, filePath);
} catch (error) {
console.error('ParsedFileCacheStore: Error invalidating cache entry:', error);
}
}
async invalidateCollection(collectionPath) {
try {
await sendCacheRequest('invalidateCollection', collectionPath);
} catch (error) {
console.error('ParsedFileCacheStore: Error invalidating collection cache:', error);
}
}
async invalidateDirectory(collectionPath, dirPath) {
try {
await sendCacheRequest('invalidateDirectory', collectionPath, dirPath);
} catch (error) {
console.error('ParsedFileCacheStore: Error invalidating directory cache:', error);
}
}
async moveEntry(collectionPath, oldFilePath, newFilePath) {
const entry = await this.getEntry(collectionPath, oldFilePath);
if (entry) {
await this.invalidate(collectionPath, oldFilePath);
await this.setEntry(collectionPath, newFilePath, {
mtimeMs: entry.mtimeMs,
parsedData: entry.parsedData
});
}
}
async getStats() {
try {
const stats = await sendCacheRequest('getStats');
return stats || {
version: '1.0.0',
totalCollections: 0,
totalFiles: 0
};
} catch (error) {
console.error('ParsedFileCacheStore: Error getting stats:', error);
return {
version: '1.0.0',
totalCollections: 0,
totalFiles: 0,
error: error.message
};
}
}
async clear() {
try {
await sendCacheRequest('clear');
} catch (error) {
console.error('ParsedFileCacheStore: Error clearing cache:', error);
}
}
async close() {
// No-op for IndexedDB version (managed by browser)
}
}
// Singleton instance
const parsedFileCacheStore = new ParsedFileCacheStore();
module.exports = {
parsedFileCacheStore,
ParsedFileCacheStore
};

View File

@@ -106,6 +106,11 @@ const defaultPreferences = {
},
display: {
zoomPercentage: 100
},
cache: {
sslSession: {
enabled: false
}
}
};
@@ -164,7 +169,12 @@ const preferencesSchema = Yup.object().shape({
}),
display: Yup.object({
zoomPercentage: Yup.number().min(50).max(150)
})
}),
cache: Yup.object({
sslSession: Yup.object({
enabled: Yup.boolean()
})
}).optional()
});
class PreferencesStore {
@@ -351,6 +361,9 @@ const preferencesUtil = {
getZoomPercentage: () => {
return get(getPreferences(), 'display.zoomPercentage', 100);
},
isSslSessionCachingEnabled: () => {
return get(getPreferences(), 'cache.sslSession.enabled', false);
},
hasLaunchedBefore: () => {
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
},

View File

@@ -1,10 +1,13 @@
const parseUrl = require('url').parse;
const https = require('node:https');
const http = require('node:http');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { interpolateString } = require('../ipc/network/interpolate-string');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { preferencesUtil } = require('../store/preferences');
const DEFAULT_PORTS = {
ftp: 21,
@@ -67,9 +70,17 @@ const shouldUseProxy = (url, proxyBypass) => {
};
/**
* Patched version of HttpsProxyAgent to get around a bug that ignores options
* such as ca and rejectUnauthorized when upgrading the proxied socket to TLS:
* https://github.com/TooTallNate/proxy-agents/issues/194
* Options that should be forwarded from the constructor to the target TLS upgrade.
*/
const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
/**
* Patched version of HttpsProxyAgent that correctly handles TLS options for
* both the proxy connection and the target server connection.
*
* The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
* ignores constructor options when upgrading the tunneled socket to TLS for the
* target server. This patch forwards the relevant TLS options to the target upgrade.
*/
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
constructor(proxy, opts) {
@@ -78,244 +89,20 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
async connect(req, opts) {
const combinedOpts = { ...this.constructorOpts, ...opts };
return super.connect(req, combinedOpts);
const targetOpts = { ...opts };
if (this.constructorOpts) {
for (const key of TARGET_TLS_OPTIONS) {
if (key in this.constructorOpts) {
targetOpts[key] = this.constructorOpts[key];
}
}
}
return super.connect(req, targetOpts);
}
}
function createTimelineHttpAgentClass(BaseAgentClass) {
return class extends BaseAgentClass {
constructor(options, timeline) {
// For proxy agents, the first argument is the proxy URI and the second is options
const { proxy: proxyUri, httpProxyAgentOptions } = options || {};
if (!proxyUri) {
throw new Error('TimelineHttpProxyAgent requires options.proxy to be set');
}
super(proxyUri, httpProxyAgentOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
// Log the proxy details
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${proxyUri}`
});
}
};
}
function createTimelineAgentClass(BaseAgentClass) {
return class extends BaseAgentClass {
constructor(options, timeline) {
let caCertificatesCount = options.caCertificatesCount || {};
delete options.caCertificatesCount;
// For proxy agents, the first argument is the proxy URI and the second is options
if (options?.proxy) {
const { proxy: proxyUri, ...agentOptions } = options;
// Ensure TLS options are properly set
const tlsOptions = {
...agentOptions,
rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
};
super(proxyUri, tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!tlsOptions.ca;
// Log TLS verification status
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
});
// Log the proxy details
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${proxyUri}`
});
} else {
// This is a regular HTTPS agent case
const tlsOptions = {
...options,
rejectUnauthorized: options.rejectUnauthorized ?? true
};
super(tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!options.ca;
// Log TLS verification status
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
});
}
this.caCertificatesCount = caCertificatesCount;
}
createConnection(options, callback) {
const { host, port } = options;
// Log ALPN protocols offered
if (this.alpnProtocols && this.alpnProtocols.length > 0) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `ALPN: offers ${this.alpnProtocols.join(', ')}`
});
}
const rootCerts = this.caCertificatesCount.root || 0;
const systemCerts = this.caCertificatesCount.system || 0;
const extraCerts = this.caCertificatesCount.extra || 0;
const customCerts = this.caCertificatesCount.custom || 0;
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`
});
// Log "Trying host:port..."
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Trying ${host}:${port}...`
});
let socket;
try {
socket = super.createConnection(options, callback);
} catch (error) {
this.timeline.push({
timestamp: new Date(),
type: 'error',
message: `Error creating connection: ${error.message}`
});
error.timeline = this.timeline;
throw error;
}
// Attach event listeners to the socket
socket?.on('lookup', (err, address, family, host) => {
if (err) {
this.timeline.push({
timestamp: new Date(),
type: 'error',
message: `DNS lookup error for ${host}: ${err.message}`
});
} else {
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `DNS lookup: ${host} -> ${address}`
});
}
});
socket?.on('connect', () => {
const address = socket.remoteAddress || host;
const remotePort = socket.remotePort || port;
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Connected to ${host} (${address}) port ${remotePort}`
});
});
socket?.on('secureConnect', () => {
const protocol = socket.getProtocol() || 'SSL/TLS';
const cipher = socket.getCipher();
const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `SSL connection using ${protocol} / ${cipherSuite}`
});
// ALPN protocol
const alpnProtocol = socket.alpnProtocol || 'None';
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `ALPN: server accepted ${alpnProtocol}`
});
// Server certificate
const cert = socket.getPeerCertificate(true);
if (cert) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `Server certificate:`
});
if (cert.subject) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`
});
}
if (cert.valid_from) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` start date: ${cert.valid_from}`
});
}
if (cert.valid_to) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` expire date: ${cert.valid_to}`
});
}
if (cert.subjectaltname) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` subjectAltName: ${cert.subjectaltname}`
});
}
if (cert.issuer) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`
});
}
// SSL certificate verify ok
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `SSL certificate verify ok.`
});
}
});
socket?.on('error', (err) => {
this.timeline.push({
timestamp: new Date(),
type: 'error',
message: `Socket error: ${err.message}`
});
});
return socket;
}
};
}
function setupProxyAgents({
requestConfig,
proxyMode = 'off',
@@ -324,6 +111,8 @@ function setupProxyAgents({
interpolationOptions,
timeline
}) {
const disableCache = !preferencesUtil.isSslSessionCachingEnabled();
// Ensure TLS options are properly set
const tlsOptions = {
...httpsAgentRequestFields,
@@ -331,21 +120,22 @@ function setupProxyAgents({
secureProtocol: undefined,
// Allow Node.js to choose the protocol
minVersion: 'TLSv1',
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true
};
const httpProxyAgentOptions = {
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true,
// Enable keepAlive for connection reuse
keepAlive: true
};
const parsedUrl = parseUrl(requestConfig.url);
const isHttpsRequest = parsedUrl.protocol === 'https:';
const hostname = parsedUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthDisabled = get(proxyConfig, 'auth.disabled', false);
const proxyAuthEnabled = !proxyAuthDisabled;
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
@@ -358,35 +148,51 @@ function setupProxyAgents({
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
if (socksEnabled) {
const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent);
requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline);
requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline);
} else {
const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: proxyUri, httpProxyAgentOptions }, timeline);
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
{ proxy: proxyUri, ...tlsOptions },
timeline
);
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${proxyProtocol}://${proxyHostname}${uriPort}`
});
}
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
// (e.g., ca certs) even for plain HTTP requests
const isHttpsProxy = proxyProtocol === 'https';
const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
// Only set the agent needed for the request protocol
if (socksEnabled) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname });
}
} else {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname });
}
}
} else {
// If proxy should not be used, set default HTTPS agent
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
} else if (proxyMode === 'system') {
const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
const parsedUrl = parseUrl(requestConfig.url);
const isHttpsRequest = parsedUrl.protocol === 'https:';
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
new URL(http_proxy);
const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: http_proxy, httpProxyAgentOptions }, timeline);
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using system proxy: ${http_proxy}`
});
}
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname });
}
} catch (error) {
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
@@ -394,25 +200,27 @@ function setupProxyAgents({
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
{ proxy: https_proxy, ...tlsOptions },
timeline
);
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using system proxy: ${https_proxy}`
});
}
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
}
} catch (error) {
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
}
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, proxyUri: null, timeline, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, proxyUri: null, timeline, disableCache, hostname });
}
}
}

View File

@@ -1,312 +0,0 @@
const { CollectionTreeBatcher, getBatcher, removeBatcher, constants } = require('../../src/app/collection-tree-batcher');
// Mock BrowserWindow
const createMockWindow = (id = 1) => {
const listeners = {};
return {
id,
isDestroyed: jest.fn(() => false),
once: jest.fn((event, callback) => {
listeners[event] = callback;
}),
emit: (event) => {
if (listeners[event]) {
listeners[event]();
}
},
webContents: {
send: jest.fn()
}
};
};
describe('CollectionTreeBatcher', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('constructor', () => {
it('should initialize with empty queue and no timer', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
expect(batcher.queue).toEqual([]);
expect(batcher.timer).toBeNull();
expect(batcher.isDestroyed).toBe(false);
});
});
describe('add()', () => {
it('should add events to the queue', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
expect(batcher.queue).toHaveLength(1);
expect(batcher.queue[0]).toEqual({
eventType: 'addFile',
payload: { path: '/test/file.bru' }
});
});
it('should schedule a flush after adding an event', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
expect(batcher.timer).not.toBeNull();
});
it('should not add events if window is destroyed', () => {
const win = createMockWindow();
win.isDestroyed.mockReturnValue(true);
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
expect(batcher.queue).toHaveLength(0);
});
it('should not add events if batcher is destroyed', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.destroy();
batcher.add('addFile', { path: '/test/file.bru' });
expect(batcher.queue).toHaveLength(0);
});
});
describe('flush()', () => {
it('should send batch to renderer and clear queue', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file1.bru' });
batcher.add('addDir', { path: '/test/folder' });
batcher.flush();
expect(win.webContents.send).toHaveBeenCalledWith('main:collection-tree-batch-updated', [
{ eventType: 'addFile', payload: { path: '/test/file1.bru' } },
{ eventType: 'addDir', payload: { path: '/test/folder' } }
]);
expect(batcher.queue).toHaveLength(0);
});
it('should not send if queue is empty', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.flush();
expect(win.webContents.send).not.toHaveBeenCalled();
});
it('should clear pending timer on flush', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
expect(batcher.timer).not.toBeNull();
batcher.flush();
expect(batcher.timer).toBeNull();
});
it('should not send if window is destroyed', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
win.isDestroyed.mockReturnValue(true);
batcher.flush();
expect(win.webContents.send).not.toHaveBeenCalled();
});
});
describe('time-based flush', () => {
it('should auto-flush after DISPATCH_INTERVAL_MS (200ms)', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
expect(win.webContents.send).not.toHaveBeenCalled();
jest.advanceTimersByTime(200);
expect(win.webContents.send).toHaveBeenCalledWith('main:collection-tree-batch-updated', [
{ eventType: 'addFile', payload: { path: '/test/file.bru' } }
]);
});
it('should not schedule multiple timers', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file1.bru' });
const firstTimer = batcher.timer;
batcher.add('addFile', { path: '/test/file2.bru' });
expect(batcher.timer).toBe(firstTimer);
});
});
describe('size-based flush', () => {
it('should auto-flush when reaching MAX_BATCH_SIZE', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
const eventCount = constants.MAX_BATCH_SIZE - 1;
// Add events - should not flush
for (let i = 0; i < eventCount; i++) {
batcher.add('addFile', { path: `/test/file${i}.bru` });
}
expect(win.webContents.send).not.toHaveBeenCalled();
expect(batcher.queue).toHaveLength(eventCount);
// Add 300th event - should trigger flush
batcher.add('addFile', { path: '/test/file299.bru' });
expect(win.webContents.send).toHaveBeenCalledTimes(1);
expect(batcher.queue).toHaveLength(0);
});
});
describe('size()', () => {
it('should return current queue size', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
expect(batcher.size()).toBe(0);
batcher.add('addFile', { path: '/test/file1.bru' });
expect(batcher.size()).toBe(1);
batcher.add('addFile', { path: '/test/file2.bru' });
expect(batcher.size()).toBe(2);
});
});
describe('clear()', () => {
it('should clear the queue without sending', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
batcher.clear();
expect(batcher.queue).toHaveLength(0);
expect(batcher.timer).toBeNull();
expect(win.webContents.send).not.toHaveBeenCalled();
});
});
describe('destroy()', () => {
it('should mark batcher as destroyed and clear queue', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
batcher.destroy();
expect(batcher.isDestroyed).toBe(true);
expect(batcher.queue).toHaveLength(0);
expect(batcher.win).toBeNull();
});
});
describe('error handling', () => {
it('should handle send errors gracefully', () => {
const win = createMockWindow();
win.webContents.send.mockImplementation(() => {
throw new Error('Window closed');
});
const batcher = new CollectionTreeBatcher(win, 'collection-1');
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
batcher.add('addFile', { path: '/test/file.bru' });
batcher.flush();
expect(consoleSpy).toHaveBeenCalledWith('CollectionTreeBatcher: Error sending batch:', expect.any(Error));
consoleSpy.mockRestore();
});
});
});
describe('getBatcher / removeBatcher', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should create and return a batcher for a window', () => {
const win = createMockWindow(100);
const batcher = getBatcher(win, 'collection-1');
expect(batcher).toBeInstanceOf(CollectionTreeBatcher);
});
it('should return the same batcher for the same window and collection', () => {
const win = createMockWindow(101);
const batcher1 = getBatcher(win, 'collection-1');
const batcher2 = getBatcher(win, 'collection-1');
expect(batcher1).toBe(batcher2);
});
it('should return different batchers for different collections', () => {
const win = createMockWindow(102);
const batcher1 = getBatcher(win, 'collection-1');
const batcher2 = getBatcher(win, 'collection-2');
expect(batcher1).not.toBe(batcher2);
});
it('should return different batchers for different windows', () => {
const win1 = createMockWindow(103);
const win2 = createMockWindow(104);
const batcher1 = getBatcher(win1, 'collection-1');
const batcher2 = getBatcher(win2, 'collection-1');
expect(batcher1).not.toBe(batcher2);
});
it('should clean up batcher when window is closed', () => {
const win = createMockWindow(105);
const batcher = getBatcher(win, 'collection-1');
batcher.add('addFile', { path: '/test/file.bru' });
// Simulate window close
win.emit('closed');
expect(batcher.isDestroyed).toBe(true);
});
it('should remove batcher with removeBatcher', () => {
const win = createMockWindow(106);
const batcher = getBatcher(win, 'collection-1');
removeBatcher(win, 'collection-1');
expect(batcher.isDestroyed).toBe(true);
// Getting batcher again should create a new one
const newBatcher = getBatcher(win, 'collection-1');
expect(newBatcher).not.toBe(batcher);
});
});

View File

@@ -257,21 +257,25 @@ class Bru {
this.globalEnvironmentVariables[key] = value;
}
deleteGlobalEnvVar(key) {
delete this.globalEnvironmentVariables[key];
}
// TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// deleteGlobalEnvVar(key) {
// delete this.globalEnvironmentVariables[key];
// }
getAllGlobalEnvVars() {
return Object.assign({}, this.globalEnvironmentVariables);
}
deleteAllGlobalEnvVars() {
for (let key in this.globalEnvironmentVariables) {
if (this.globalEnvironmentVariables.hasOwnProperty(key)) {
delete this.globalEnvironmentVariables[key];
}
}
}
// TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// deleteAllGlobalEnvVars() {
// for (let key in this.globalEnvironmentVariables) {
// if (this.globalEnvironmentVariables.hasOwnProperty(key)) {
// delete this.globalEnvironmentVariables[key];
// }
// }
// }
getOauth2CredentialVar(key) {
return this.interpolate(this.oauth2CredentialVariables[key]);
@@ -345,40 +349,48 @@ class Bru {
return this.interpolate(this.collectionVariables[key]);
}
setCollectionVar(key, value) {
if (!key) {
throw new Error('Creating a variable without specifying a name is not allowed.');
}
if (variableNameRegex.test(key) === false) {
throw new Error(
`Variable name: "${key}" contains invalid characters!`
+ ' Names must only contain alpha-numeric characters, "-", "_", "."'
);
}
this.collectionVariables[key] = value;
}
// TODO: setCollectionVar works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// setCollectionVar(key, value) {
// if (!key) {
// throw new Error('Creating a variable without specifying a name is not allowed.');
// }
//
// if (variableNameRegex.test(key) === false) {
// throw new Error(
// `Variable name: "${key}" contains invalid characters!`
// + ' Names must only contain alpha-numeric characters, "-", "_", "."'
// );
// }
//
// this.collectionVariables[key] = value;
// }
hasCollectionVar(key) {
return Object.hasOwn(this.collectionVariables, key);
}
deleteCollectionVar(key) {
delete this.collectionVariables[key];
}
// TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// deleteCollectionVar(key) {
// delete this.collectionVariables[key];
// }
deleteAllCollectionVars() {
for (let key in this.collectionVariables) {
if (this.collectionVariables.hasOwnProperty(key)) {
delete this.collectionVariables[key];
}
}
}
// TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// deleteAllCollectionVars() {
// for (let key in this.collectionVariables) {
// if (this.collectionVariables.hasOwnProperty(key)) {
// delete this.collectionVariables[key];
// }
// }
// }
getAllCollectionVars() {
return Object.assign({}, this.collectionVariables);
}
// TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// getAllCollectionVars() {
// return Object.assign({}, this.collectionVariables);
// }
getFolderVar(key) {
return this.interpolate(this.folderVariables[key]);

View File

@@ -101,11 +101,13 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'setGlobalEnvVar', setGlobalEnvVar);
setGlobalEnvVar.dispose();
let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
bru.deleteGlobalEnvVar(vm.dump(key));
});
vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
deleteGlobalEnvVar.dispose();
// TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
// bru.deleteGlobalEnvVar(vm.dump(key));
// });
// vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
// deleteGlobalEnvVar.dispose();
let getAllGlobalEnvVars = vm.newFunction('getAllGlobalEnvVars', function () {
return marshallToVm(bru.getAllGlobalEnvVars(), vm);
@@ -113,11 +115,13 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getAllGlobalEnvVars', getAllGlobalEnvVars);
getAllGlobalEnvVars.dispose();
let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
bru.deleteAllGlobalEnvVars();
});
vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
deleteAllGlobalEnvVars.dispose();
// TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
// bru.deleteAllGlobalEnvVars();
// });
// vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
// deleteAllGlobalEnvVars.dispose();
let hasVar = vm.newFunction('hasVar', function (key) {
return marshallToVm(bru.hasVar(vm.dump(key)), vm);
@@ -209,11 +213,13 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getCollectionVar', getCollectionVar);
getCollectionVar.dispose();
let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
bru.setCollectionVar(vm.dump(key), vm.dump(value));
});
vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
setCollectionVar.dispose();
// TODO: setCollectionVar works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
// bru.setCollectionVar(vm.dump(key), vm.dump(value));
// });
// vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
// setCollectionVar.dispose();
let hasCollectionVar = vm.newFunction('hasCollectionVar', function (key) {
return marshallToVm(bru.hasCollectionVar(vm.dump(key)), vm);
@@ -221,23 +227,29 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'hasCollectionVar', hasCollectionVar);
hasCollectionVar.dispose();
let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
bru.deleteCollectionVar(vm.dump(key));
});
vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
deleteCollectionVar.dispose();
// TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
// bru.deleteCollectionVar(vm.dump(key));
// });
// vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
// deleteCollectionVar.dispose();
let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
bru.deleteAllCollectionVars();
});
vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
deleteAllCollectionVars.dispose();
// TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
// bru.deleteAllCollectionVars();
// });
// vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
// deleteAllCollectionVars.dispose();
let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
return marshallToVm(bru.getAllCollectionVars(), vm);
});
vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
getAllCollectionVars.dispose();
// TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
// Re-enable once the UI sync issue is resolved.
// let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
// return marshallToVm(bru.getAllCollectionVars(), vm);
// });
// vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
// getAllCollectionVars.dispose();
let getTestResults = vm.newFunction('getTestResults', () => {
const promise = vm.newPromise();

View File

@@ -9,6 +9,7 @@ export { default as createVaultClient, VaultError } from './utils/node-vault';
export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault';
export { getHttpHttpsAgents } from './utils/http-https-agents';
export { initializeShellEnv } from './utils/shell-env';
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
export * as scripting from './scripting';

View File

@@ -0,0 +1,374 @@
import https from 'node:https';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './agent-cache';
describe('Agent Cache', () => {
beforeEach(() => {
clearAgentCache();
});
describe('getOrCreateHttpsAgent', () => {
it('creates a new agent when cache is empty', () => {
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
expect(agent).toBeInstanceOf(https.Agent);
expect(getAgentCacheSize()).toBe(1);
});
it('returns cached agent for identical options', () => {
const options = { rejectUnauthorized: true, keepAlive: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
expect(agent1).toBe(agent2);
expect(getAgentCacheSize()).toBe(1);
});
it('creates separate agents for different rejectUnauthorized values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different CA certificates', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-a' } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-b' } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different cert values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-a') } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-b') } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different key values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-a') } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-b') } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different pfx values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-a') } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-b') } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different passphrase values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-a' } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-b' } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different proxy URIs', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy1:8080' });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy2:8080' });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different agent classes', () => {
const options = { keepAlive: true };
const httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
const httpAgent = getOrCreateHttpsAgent({ AgentClass: http.Agent, options });
expect(httpsAgent).not.toBe(httpAgent);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different keepAlive values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: true } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: false } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different hostnames', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'auth.example.com' });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('returns cached agent for the same hostname', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
expect(agent1).toBe(agent2);
expect(getAgentCacheSize()).toBe(1);
});
it('creates separate agents for null hostname vs explicit hostname', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: null });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
});
describe('timeline support', () => {
it('does not add timeline when none is provided', () => {
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {} }) as any;
expect(agent.timeline).toBeUndefined();
});
it('uses provided timeline array', () => {
const timeline: any[] = [];
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline }) as any;
expect(agent.timeline).toBe(timeline);
});
it('updates timeline reference on cached agents', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
expect(agent1.timeline).toBe(timeline1);
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
expect(agent1).toBe(agent2);
expect(agent2.timeline).toBe(timeline2);
});
it('logs when reusing a cached HTTPS agent', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
// First call creates new agent - no reuse message
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 });
expect(timeline1.some((e) => e.message.includes('Reusing cached https agent'))).toBe(false);
// Second call reuses cached agent - should log reuse message with SSL session reuse
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 });
expect(timeline2.some((e) => e.message.includes('Reusing cached https agent'))).toBe(true);
});
it('logs when reusing a cached HTTP agent', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
// First call creates new agent - no reuse message
getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline1 });
expect(timeline1.some((e) => e.message.includes('Reusing cached http agent'))).toBe(false);
// Second call reuses cached agent - should log reuse message with connection reuse
getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline2 });
expect(timeline2.some((e) => e.message.includes('Reusing cached http agent'))).toBe(true);
});
it('logs SSL validation status on agent creation', () => {
const timeline: any[] = [];
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true }, timeline });
const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
expect(sslEntry).toBeDefined();
expect(sslEntry.message).toContain('enabled');
});
it('logs SSL validation disabled when rejectUnauthorized is false', () => {
const timeline: any[] = [];
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false }, timeline });
const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
expect(sslEntry).toBeDefined();
expect(sslEntry.message).toContain('disabled');
});
});
describe('clearAgentCache', () => {
it('removes all cached agents', () => {
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
expect(getAgentCacheSize()).toBe(2);
clearAgentCache();
expect(getAgentCacheSize()).toBe(0);
});
it('destroys all agents when clearing cache', () => {
const destroyMocks: jest.Mock[] = [];
// Create several agents and attach mock destroy functions
for (let i = 0; i < 5; i++) {
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } }) as any;
const mock = jest.fn();
agent.destroy = mock;
destroyMocks.push(mock);
}
expect(getAgentCacheSize()).toBe(5);
clearAgentCache();
expect(getAgentCacheSize()).toBe(0);
// All agents should have been destroyed
destroyMocks.forEach((mock) => {
expect(mock).toHaveBeenCalled();
});
});
});
describe('LRU eviction', () => {
it('maintains cache size under limit', () => {
// Create many agents with different options
for (let i = 0; i < 150; i++) {
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
}
// Cache should be capped at MAX_AGENT_CACHE_SIZE (100)
expect(getAgentCacheSize()).toBeLessThanOrEqual(100);
});
it('destroys evicted agents to prevent memory leaks', () => {
// Create first agent and attach a mock destroy function
const firstAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-to-evict' } }) as any;
const destroyMock = jest.fn();
firstAgent.destroy = destroyMock;
// Fill cache to trigger eviction (100 more agents will evict the first one)
for (let i = 0; i < 100; i++) {
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
}
// First agent should have been evicted and destroyed
expect(destroyMock).toHaveBeenCalled();
});
});
describe('concurrent requests timeline isolation', () => {
it('isolates timeline events for concurrent requests using the same cached agent', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
// Get the same agent twice with different timelines (simulating concurrent requests)
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
// Both should return the same cached agent
expect(agent1).toBe(agent2);
// Create mock sockets to simulate concurrent connections
const mockSocket1 = new EventEmitter() as any;
mockSocket1.remoteAddress = '1.2.3.4';
mockSocket1.remotePort = 443;
mockSocket1.getProtocol = () => 'TLSv1.3';
mockSocket1.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
mockSocket1.alpnProtocol = 'h2';
mockSocket1.getPeerCertificate = () => ({
subject: { CN: 'example.com' },
valid_from: 'Jan 1 00:00:00 2024 GMT',
valid_to: 'Jan 1 00:00:00 2025 GMT'
});
mockSocket1.authorized = true;
const mockSocket2 = new EventEmitter() as any;
mockSocket2.remoteAddress = '5.6.7.8';
mockSocket2.remotePort = 443;
mockSocket2.getProtocol = () => 'TLSv1.3';
mockSocket2.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
mockSocket2.alpnProtocol = 'http/1.1';
mockSocket2.getPeerCertificate = () => ({
subject: { CN: 'other.com' },
valid_from: 'Jan 1 00:00:00 2024 GMT',
valid_to: 'Jan 1 00:00:00 2025 GMT'
});
mockSocket2.authorized = true;
// Mock createConnection to return our mock sockets
const originalCreateConnection = Object.getPrototypeOf(Object.getPrototypeOf(agent1)).createConnection;
let callCount = 0;
jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent1)), 'createConnection').mockImplementation(function (this: any, options: any, callback: any) {
callCount++;
return callCount === 1 ? mockSocket1 : mockSocket2;
});
// Simulate request 1 starting - this captures timeline1 in the closure
agent1.timeline = timeline1;
const socket1 = agent1.createConnection({ host: 'example.com', port: 443 }, () => {});
// Before request 1's events fire, request 2 starts and updates agent.timeline
// This simulates the race condition
agent1.timeline = timeline2;
const socket2 = agent1.createConnection({ host: 'other.com', port: 443 }, () => {});
// Now fire events for both sockets - they should go to their respective timelines
mockSocket1.emit('connect');
mockSocket1.emit('secureConnect');
mockSocket2.emit('connect');
mockSocket2.emit('secureConnect');
// Verify timeline1 only contains events for request 1 (example.com)
const timeline1Messages = timeline1.map((e) => e.message);
expect(timeline1Messages.some((m) => m.includes('example.com'))).toBe(true);
expect(timeline1Messages.some((m) => m.includes('other.com'))).toBe(false);
// Verify timeline2 only contains events for request 2 (other.com)
const timeline2Messages = timeline2.map((e) => e.message);
expect(timeline2Messages.some((m) => m.includes('other.com'))).toBe(true);
expect(timeline2Messages.some((m) => m.includes('example.com'))).toBe(false);
// Restore the original implementation
jest.restoreAllMocks();
});
it('logs events to captured timeline even after agent.timeline is reassigned', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
// Create a mock socket
const mockSocket = new EventEmitter() as any;
mockSocket.remoteAddress = '1.2.3.4';
mockSocket.remotePort = 443;
// Mock createConnection
jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent)), 'createConnection').mockImplementation(() => mockSocket);
// Start creating connection - this captures timeline1
const socket = agent.createConnection({ host: 'test.com', port: 443 }, () => {});
// Reassign agent.timeline (simulating another request coming in)
agent.timeline = timeline2;
// Fire the connect event - this should still go to timeline1 (captured reference)
mockSocket.emit('connect');
// Verify event went to timeline1, not timeline2
expect(timeline1.some((e) => e.message.includes('Connected to test.com'))).toBe(true);
expect(timeline2.some((e) => e.message.includes('Connected to test.com'))).toBe(false);
jest.restoreAllMocks();
});
});
});

View File

@@ -0,0 +1,393 @@
import crypto from 'node:crypto';
import tls from 'node:tls';
import type { Agent as HttpAgent } from 'node:http';
import type { Agent as HttpsAgent } from 'node:https';
import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent';
/**
* Agent cache for SSL session reuse.
* Agents are cached by their configuration to enable TLS session resumption,
* which significantly reduces SSL handshake time for repeated requests.
*/
const agentCache = new Map<string, HttpAgent | HttpsAgent>();
/**
* Maximum number of agents to cache.
* 100 provides a good balance between memory usage and SSL session reuse.
* Each agent maintains persistent connections, so higher values increase memory.
* Lower values may reduce SSL session hits for users with many different TLS configs.
*/
const MAX_AGENT_CACHE_SIZE = 100;
/**
* Cache for timeline-wrapped HTTPS agent classes.
* Prevents creating new class definitions on every call.
*/
const timelineClassCache = new WeakMap<any, AgentClass>();
/**
* Cache for timeline-wrapped HTTP agent classes.
* Prevents creating new class definitions on every call.
*/
const timelineHttpClassCache = new WeakMap<any, HttpAgentClass>();
/**
* Map to assign unique IDs to agent classes.
* Used for cache key generation since different classes may have the same name.
*/
const agentClassIdMap = new WeakMap<any, number>();
let agentClassIdCounter = 0;
function getAgentClassId(AgentClass: any): number {
if (agentClassIdMap.has(AgentClass)) {
return agentClassIdMap.get(AgentClass)!;
}
const id = ++agentClassIdCounter;
agentClassIdMap.set(AgentClass, id);
return id;
}
/**
* Hash a value using SHA-256 and return a truncated hex string.
* Truncated to 16 chars for compact cache keys while maintaining uniqueness.
*/
function hashValue(value: string | Buffer | undefined): string | null {
if (!value) return null;
const data = Buffer.isBuffer(value) ? value : String(value);
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
}
/**
* Cache for secure contexts created from CA options.
* Keyed by the hash of the CA value to avoid creating duplicate contexts.
*/
const secureContextCache = new Map<string, tls.SecureContext>();
/**
* Build a TLS secure context that adds custom CAs on top of the OpenSSL defaults.
*
* When Node.js receives an explicit `ca` option in tls.connect() or https.Agent,
* it replaces the default CA store entirely. This means CAs that are only in the
* OpenSSL default trust store (e.g. /etc/ssl/cert.pem) but not in
* tls.rootCertificates or tls.getCACertificates('system') are lost.
*
* This function creates a secureContext starting from the OpenSSL defaults
* and adds custom CAs on top via addCACert(), which appends rather than replaces.
*/
function buildSecureContext(ca: string | Buffer | (string | Buffer)[]): tls.SecureContext {
const caHash = hashCaValue(ca);
if (caHash && secureContextCache.has(caHash)) {
return secureContextCache.get(caHash)!;
}
const ctx = tls.createSecureContext();
const caList = Array.isArray(ca) ? ca : [ca];
for (const cert of caList) {
if (cert) {
ctx.context.addCACert(cert);
}
}
if (caHash) {
secureContextCache.set(caHash, ctx);
}
return ctx;
}
/**
* Convert agent options to use a secureContext instead of raw `ca`.
* This ensures custom CAs are added on top of the OpenSSL defaults
* rather than replacing the default CA store.
*
* When client certificates (pfx/cert/key) are also present, they are loaded
* into the secure context so they aren't silently ignored by Node.js
* (Node.js skips pfx/cert/key/ca when a secureContext is provided).
*/
function applySecureContext<T extends AgentOptions | HttpAgentOptions>(options: T): T {
if ('ca' in options && (options as AgentOptions).ca) {
const { ca, ...rest } = options as AgentOptions;
// When client certs are present alongside CA, build a combined context
// that includes both. This context can't be CA-cached since it's unique
// per client cert + CA combination.
const hasClientCert = rest.pfx || rest.cert || rest.key;
if (hasClientCert) {
const ctxOptions: Record<string, any> = {};
if (rest.pfx) ctxOptions.pfx = rest.pfx;
if (rest.cert) ctxOptions.cert = rest.cert;
if (rest.key) ctxOptions.key = rest.key;
if (rest.passphrase) ctxOptions.passphrase = rest.passphrase;
const ctx = tls.createSecureContext(ctxOptions);
const caList = Array.isArray(ca) ? ca : [ca!];
for (const caCert of caList) {
if (caCert) ctx.context.addCACert(caCert);
}
const { pfx: _pfx, cert: _cert, key: _key, passphrase: _pass, ...cleanRest } = rest;
return { ...cleanRest, secureContext: ctx } as unknown as T;
}
// CA-only case: use cached secure context
return { ...rest, secureContext: buildSecureContext(ca!) } as unknown as T;
}
return options;
}
/**
* Hash a CA value which can be a single value or an array of certificates.
* Node.js TLS options allow ca to be string | Buffer | (string | Buffer)[].
*/
function hashCaValue(value: string | Buffer | (string | Buffer)[] | undefined): string | null {
if (!value) return null;
if (Array.isArray(value)) {
// Concatenate all values with separator and hash together
const combined = value.map((v) => (Buffer.isBuffer(v) ? v.toString('base64') : String(v))).join('|');
return crypto.createHash('sha256').update(combined).digest('hex').slice(0, 16);
}
const data = Buffer.isBuffer(value) ? value : String(value);
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
}
/**
* Generate a cache key from HTTPS agent options.
* Uses a hash of the serialized options to create a compact key.
*/
function getAgentCacheKey(agentClassId: number, options: AgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
// Extract the TLS-relevant options for the cache key
const keyData = {
agentClassId,
hostname: proxyUri?.length ? null : hostname,
proxyUri,
keepAlive: options.keepAlive,
rejectUnauthorized: options.rejectUnauthorized,
// Hash certificates and passphrase instead of including full content
ca: hashCaValue(options.ca),
cert: hashValue(options.cert),
key: hashValue(options.key),
pfx: hashValue(options.pfx),
passphrase: hashValue(options.passphrase),
minVersion: options.minVersion,
secureProtocol: options.secureProtocol
};
return JSON.stringify(keyData);
}
/**
* Generate a cache key from HTTP agent options.
* Simpler than HTTPS since no TLS options are involved.
*/
function getHttpAgentCacheKey(agentClassId: number, options: HttpAgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
const keyData = {
agentClassId,
hostname: proxyUri?.length ? null : hostname,
proxyUri,
keepAlive: options.keepAlive
};
return JSON.stringify(keyData);
}
/**
* Get a cached timeline-wrapped HTTPS agent class.
* Creates the wrapped class once and caches it for reuse.
*/
function getTimelineAgentClass(BaseAgentClass: any): AgentClass {
if (timelineClassCache.has(BaseAgentClass)) {
return timelineClassCache.get(BaseAgentClass)!;
}
const wrappedClass = createTimelineAgentClass(BaseAgentClass);
timelineClassCache.set(BaseAgentClass, wrappedClass);
return wrappedClass;
}
/**
* Get a cached timeline-wrapped HTTP agent class.
* Creates the wrapped class once and caches it for reuse.
*/
function getTimelineHttpAgentClass(BaseAgentClass: any): HttpAgentClass {
if (timelineHttpClassCache.has(BaseAgentClass)) {
return timelineHttpClassCache.get(BaseAgentClass)!;
}
const wrappedClass = createTimelineHttpAgentClass(BaseAgentClass);
timelineHttpClassCache.set(BaseAgentClass, wrappedClass);
return wrappedClass;
}
/**
* Type for cache key generation functions.
*/
type CacheKeyFn<T> = (classId: number, options: T, proxyUri: string | null, hostname: string | null) => string;
/**
* Type for timeline class wrapper functions.
*/
type TimelineClassFn = (base: any) => AgentClass | HttpAgentClass;
/**
* Internal helper for agent caching with LRU eviction.
* Shared logic for both HTTP and HTTPS agents.
*/
function getOrCreateAgentInternal<TOptions extends HttpAgentOptions>(
BaseAgentClass: any,
options: TOptions,
proxyUri: string | null,
timeline: TimelineEntry[] | null,
getCacheKey: CacheKeyFn<TOptions>,
getTimelineClass: TimelineClassFn,
cacheHitMessage: string,
disableCache: boolean = false,
hostname: string | null = null
): HttpAgent | HttpsAgent {
const agentClassId = getAgentClassId(BaseAgentClass);
const cacheKey = getCacheKey(agentClassId, options, proxyUri, hostname);
if (!disableCache && agentCache.has(cacheKey)) {
// Move to end for LRU (delete and re-add)
const agent = agentCache.get(cacheKey)!;
agentCache.delete(cacheKey);
agentCache.set(cacheKey, agent);
// Update timeline reference for new request
// The cached agent was created with a previous timeline,
// but we need events to go to the current request's timeline
if (timeline && 'timeline' in agent) {
(agent as any).timeline = timeline;
}
// Log that we're reusing a cached agent
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: cacheHitMessage
});
}
return agent;
}
const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
// Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults
const resolvedOptions = applySecureContext(options);
let agent: HttpAgent | HttpsAgent;
if (timeline) {
// Timeline-wrapped classes handle proxy internally via options.proxy
const agentOptions = proxyUri ? { ...resolvedOptions, proxy: proxyUri } : resolvedOptions;
agent = new AgentClass(agentOptions, timeline);
} else if (proxyUri) {
// Proxy agent classes expect (proxyUri, options) constructor signature
agent = new BaseAgentClass(proxyUri, resolvedOptions);
} else {
agent = new BaseAgentClass(resolvedOptions);
}
if (!disableCache) {
// Evict oldest entry if cache is full (LRU eviction)
if (agentCache.size >= MAX_AGENT_CACHE_SIZE) {
const firstKey = agentCache.keys().next().value;
if (firstKey !== undefined) {
const evictedAgent = agentCache.get(firstKey);
agentCache.delete(firstKey);
// Destroy the agent to release its sockets and prevent memory leaks
if (evictedAgent && typeof (evictedAgent as any).destroy === 'function') {
(evictedAgent as any).destroy();
}
}
}
agentCache.set(cacheKey, agent);
}
return agent;
}
/**
* Get or create a cached HTTPS agent.
* Reuses existing agents to enable SSL session caching.
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
* Automatically wraps the agent class with timeline logging support.
*/
function getOrCreateHttpsAgent({
AgentClass,
options,
proxyUri = null,
timeline = null,
disableCache = false,
hostname = null
}: {
AgentClass: any;
options: AgentOptions;
proxyUri?: string | null;
timeline?: TimelineEntry[] | null;
disableCache?: boolean;
hostname?: string | null;
}): HttpAgent | HttpsAgent {
return getOrCreateAgentInternal(
AgentClass,
options,
proxyUri,
timeline,
getAgentCacheKey,
getTimelineAgentClass,
'Reusing cached https agent',
disableCache,
hostname
);
}
/**
* Get or create a cached HTTP agent.
* Reuses existing agents to enable connection reuse.
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
* Automatically wraps the agent class with timeline logging support.
*/
function getOrCreateHttpAgent({
AgentClass,
options,
proxyUri = null,
timeline = null,
disableCache = false,
hostname = null
}: {
AgentClass: any;
options: HttpAgentOptions;
proxyUri?: string | null;
timeline?: TimelineEntry[] | null;
disableCache?: boolean;
hostname?: string | null;
}): HttpAgent {
return getOrCreateAgentInternal(
AgentClass,
options,
proxyUri,
timeline,
getHttpAgentCacheKey,
getTimelineHttpAgentClass,
'Reusing cached http agent',
disableCache,
hostname
) as HttpAgent;
}
/**
* Clear the agent cache. Useful for testing or when SSL configuration changes.
* Destroys all cached agents to properly release their sockets.
*/
function clearAgentCache(): void {
for (const agent of agentCache.values()) {
if (agent && typeof (agent as any).destroy === 'function') {
(agent as any).destroy();
}
}
agentCache.clear();
}
/**
* Get the current size of the agent cache.
*/
function getAgentCacheSize(): number {
return agentCache.size;
}
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize };

View File

@@ -1,5 +1,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import type { Agent as HttpAgent } from 'node:http';
import type { Agent as HttpsAgent } from 'node:https';
@@ -10,6 +11,8 @@ import { HttpProxyAgent } from 'http-proxy-agent';
import { isEmpty, get, isUndefined, isNull } from 'lodash';
import { getCACertificates } from './ca-cert';
import { transformProxyConfig } from './proxy-util';
import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache';
import type { TimelineEntry } from './timeline-agent';
const DEFAULT_PORTS: Record<string, number> = {
ftp: 21,
@@ -93,6 +96,7 @@ type ConfigOptions = {
shouldUseCustomCaCertificate: boolean;
customCaCertificateFilePath?: string;
shouldKeepDefaultCaCertificates: boolean;
cacheSslSession?: boolean;
};
type GetCertsAndProxyConfigParams = {
@@ -120,6 +124,8 @@ type CreateAgentsParams = {
certsConfig: CertsConfig;
httpsAgentRequestFields: HttpsAgentRequestFields;
systemProxyConfig?: SystemProxyConfig;
timeline?: TimelineEntry[];
disableCache?: boolean;
};
type GetHttpHttpsAgentsParams = {
@@ -132,6 +138,7 @@ type GetHttpHttpsAgentsParams = {
collectionLevelProxy?: ProxyConfig;
appLevelProxyConfig?: Record<string, any>;
systemProxyConfig?: SystemProxyConfig;
timeline?: TimelineEntry[];
};
/**
@@ -188,9 +195,21 @@ const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined
};
/**
* Patched version of HttpsProxyAgent to get around a bug that ignores options
* such as ca and rejectUnauthorized when upgrading the proxied socket to TLS:
* https://github.com/TooTallNate/proxy-agents/issues/194
* Options that should be forwarded from the constructor to the target TLS upgrade.
* The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
* ignores constructor options when upgrading the tunneled socket to TLS for the
* target server. This list covers client certificates, verification, and secure context.
*/
const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'] as const;
/**
* Patched version of HttpsProxyAgent that correctly handles TLS options for
* both the proxy connection and the target server connection.
*
* This patch forwards client certificate options, rejectUnauthorized, and
* secureContext to the target TLS upgrade. The agent-cache layer converts raw
* `ca` to a secureContext (via addCACert) before construction, so custom CAs
* are added on top of the OpenSSL defaults rather than replacing them.
*/
class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
private constructorOpts: any;
@@ -201,8 +220,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
}
async connect(req: any, opts: any) {
const combinedOpts = { ...this.constructorOpts, ...opts };
return super.connect(req, combinedOpts);
const targetOpts = { ...opts };
// Forward TLS options to the target TLS upgrade
if (this.constructorOpts) {
for (const key of TARGET_TLS_OPTIONS) {
if (key in this.constructorOpts) {
targetOpts[key] = this.constructorOpts[key];
}
}
}
return super.connect(req, targetOpts);
}
}
@@ -336,13 +365,24 @@ const getCertsAndProxyConfig = ({
return { proxyMode, proxyConfig, certsConfig };
};
function extractHostname(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).hostname || null;
} catch {
return null;
}
}
function createAgents({
requestUrl,
proxyMode,
proxyConfig,
systemProxyConfig,
certsConfig,
httpsAgentRequestFields
httpsAgentRequestFields,
timeline,
disableCache = true
}: CreateAgentsParams): AgentResult {
// Ensure TLS options are properly set
const tlsOptions: TlsOptions = {
@@ -358,13 +398,19 @@ function createAgents({
let httpAgent: HttpAgent | undefined;
let httpsAgent: HttpsAgent | HttpsProxyAgent<any> | SocksProxyAgent | undefined;
// Determine if this is an HTTPS request
const isHttpsRequest = requestUrl ? requestUrl.startsWith('https:') : true;
// Extract hostname for per-host agent caching (enables TLS session reuse per host)
const hostname = extractHostname(requestUrl);
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = get(proxyConfig, 'protocol');
const proxyHostname = get(proxyConfig, 'hostname');
const proxyPort = get(proxyConfig, 'port');
const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol && proxyProtocol.includes('socks');
if (!proxyProtocol || !proxyHostname) {
@@ -381,16 +427,31 @@ function createAgents({
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
// (e.g., ca certs) even for plain HTTP requests
const isHttpsProxy = proxyProtocol === 'https';
const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
// Only set the agent needed for the request protocol
if (socksEnabled) {
httpAgent = new SocksProxyAgent(proxyUri);
httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any);
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
}
} else {
httpAgent = new HttpProxyAgent(proxyUri);
httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions);
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
}
}
} else {
// If proxy should not be used, set default HTTPS agent
httpsAgent = new https.Agent(tlsOptions as any);
// If proxy should not be used, only set HTTPS agent for HTTPS requests
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
}
// HTTP requests without proxy don't need a custom agent
}
} else if (proxyMode === 'system') {
const http_proxy = get(systemProxyConfig, 'http_proxy');
@@ -399,28 +460,32 @@ function createAgents({
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length) {
new URL(http_proxy);
httpAgent = new HttpProxyAgent(http_proxy);
if (http_proxy?.length && !isHttpsRequest) {
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions as any, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length) {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any);
} else {
httpsAgent = new https.Agent(tlsOptions as any);
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
httpsAgent = new https.Agent(tlsOptions as any);
}
} else {
httpsAgent = new https.Agent(tlsOptions as any);
}
if (!httpAgent && !httpsAgent) {
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline || null, disableCache, hostname });
}
}
return { httpAgent, httpsAgent };
@@ -433,7 +498,8 @@ const getHttpHttpsAgents = async ({
collectionLevelProxy,
appLevelProxyConfig,
systemProxyConfig,
options
options,
timeline
}: GetHttpHttpsAgentsParams): Promise<AgentResult> => {
const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({
requestUrl,
@@ -460,7 +526,9 @@ const getHttpHttpsAgents = async ({
proxyConfig,
systemProxyConfig,
certsConfig,
httpsAgentRequestFields
httpsAgentRequestFields,
timeline,
disableCache: !options.cacheSslSession
});
return { httpAgent, httpsAgent };

View File

@@ -0,0 +1,309 @@
import http from 'node:http';
import https from 'node:https';
type TimelineEntry = {
timestamp: Date;
type: 'info' | 'tls' | 'error';
message: string;
};
type CaCertificatesCount = {
root?: number;
system?: number;
extra?: number;
custom?: number;
};
type AgentOptions = {
rejectUnauthorized?: boolean;
ca?: string | string[] | Buffer | Buffer[];
cert?: string | Buffer;
key?: string | Buffer;
pfx?: string | Buffer;
passphrase?: string;
minVersion?: string;
secureProtocol?: string;
keepAlive?: boolean;
ALPNProtocols?: string[];
caCertificatesCount?: CaCertificatesCount;
proxy?: string;
secureContext?: any;
};
type AgentClass = new (options: AgentOptions, timeline?: TimelineEntry[]) => https.Agent;
type ProxyAgentClass = new (proxyUri: string, options?: AgentOptions) => https.Agent;
type HttpAgentOptions = {
keepAlive?: boolean;
proxy?: string;
};
type HttpAgentClass = new (options: HttpAgentOptions, timeline?: TimelineEntry[]) => http.Agent;
type HttpProxyAgentClass = new (proxyUri: string, options?: HttpAgentOptions) => http.Agent;
/**
* Creates a timeline-aware agent class that logs TLS connection events.
* The returned class wraps the base agent and adds timeline logging for:
* - SSL validation status
* - Proxy usage
* - ALPN protocol negotiation
* - CA certificates info
* - DNS lookups
* - Connection establishment
* - TLS handshake details
* - Server certificate info
*/
function createTimelineAgentClass<T extends ProxyAgentClass | typeof https.Agent>(BaseAgentClass: T): AgentClass {
return class TimelineAgent extends (BaseAgentClass as any) {
timeline: TimelineEntry[];
alpnProtocols: string[];
caProvided: boolean;
caCertificatesCount: CaCertificatesCount;
/**
* Helper method to log entries to the timeline.
*/
private log(type: 'info' | 'tls' | 'error', message: string): void {
this.timeline.push({
timestamp: new Date(),
type,
message
});
}
constructor(options: AgentOptions, timeline?: TimelineEntry[]) {
const caCertificatesCount = options.caCertificatesCount || {};
const optionsCopy = { ...options };
delete optionsCopy.caCertificatesCount;
// For proxy agents, the first argument is the proxy URI and the second is options
if (optionsCopy?.proxy) {
const { proxy: proxyUri, ...agentOptions } = optionsCopy;
// Ensure TLS options are properly set
const tlsOptions = {
...agentOptions,
rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
};
super(proxyUri, tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!(tlsOptions.ca || tlsOptions.secureContext);
// Log TLS verification status and proxy details
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
this.log('info', `Using proxy: ${proxyUri}`);
} else {
// This is a regular HTTPS agent case
const tlsOptions = {
...optionsCopy,
rejectUnauthorized: optionsCopy.rejectUnauthorized ?? true
};
super(tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = optionsCopy.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!(optionsCopy.ca || optionsCopy.secureContext);
// Log TLS verification status
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
}
this.caCertificatesCount = caCertificatesCount;
}
createConnection(options: any, callback: any) {
const { host, port } = options;
// Capture the current timeline reference to avoid race conditions
// when multiple concurrent requests reuse the same cached agent
const timeline = this.timeline;
const log = (type: 'info' | 'tls' | 'error', message: string): void => {
timeline.push({
timestamp: new Date(),
type,
message
});
};
// Log ALPN protocols offered
if (this.alpnProtocols && this.alpnProtocols.length > 0) {
log('tls', `ALPN: offers ${this.alpnProtocols.join(', ')}`);
}
const rootCerts = this.caCertificatesCount.root || 0;
const systemCerts = this.caCertificatesCount.system || 0;
const extraCerts = this.caCertificatesCount.extra || 0;
const customCerts = this.caCertificatesCount.custom || 0;
log('tls', `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`);
// Log "Trying host:port..."
log('info', `Trying ${host}:${port}...`);
let socket: any;
try {
socket = super.createConnection(options, callback);
} catch (error: any) {
log('error', `Error creating connection: ${error.message}`);
error.timeline = timeline;
throw error;
}
// Attach event listeners to the socket
socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
if (err) {
log('error', `DNS lookup error for ${host}: ${err.message}`);
} else {
log('info', `DNS lookup: ${host} -> ${address}`);
}
});
socket?.on('connect', () => {
const address = socket.remoteAddress || host;
const remotePort = socket.remotePort || port;
log('info', `Connected to ${host} (${address}) port ${remotePort}`);
});
socket?.on('secureConnect', () => {
const protocol = socket.getProtocol?.() || 'SSL/TLS';
const cipher = socket.getCipher?.();
const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
log('tls', `SSL connection using ${protocol} / ${cipherSuite}`);
// ALPN protocol
const alpnProtocol = socket.alpnProtocol || 'None';
log('tls', `ALPN: server accepted ${alpnProtocol}`);
// Server certificate
const cert = socket.getPeerCertificate?.(true);
if (cert) {
log('tls', `Server certificate:`);
if (cert.subject) {
log('tls', ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`);
}
if (cert.valid_from) {
log('tls', ` start date: ${cert.valid_from}`);
}
if (cert.valid_to) {
log('tls', ` expire date: ${cert.valid_to}`);
}
if (cert.subjectaltname) {
log('tls', ` subjectAltName: ${cert.subjectaltname}`);
}
if (cert.issuer) {
log('tls', ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`);
}
// SSL certificate verification status
if (socket.authorized !== false) {
log('tls', `SSL certificate verify ok.`);
} else {
log('tls', `SSL certificate verification skipped (rejectUnauthorized: false).`);
}
}
});
socket?.on('error', (err: Error) => {
log('error', `Socket error: ${err.message}`);
});
return socket;
}
} as unknown as AgentClass;
}
/**
* Creates a timeline-aware HTTP agent class that logs connection events.
* The returned class wraps the base HTTP agent and adds timeline logging for:
* - Proxy usage (when applicable)
* - DNS lookups
* - Connection establishment
* - Errors
*
* This is a simplified version of createTimelineAgentClass for HTTP (non-TLS) connections.
*/
function createTimelineHttpAgentClass<T extends HttpProxyAgentClass | typeof http.Agent>(BaseAgentClass: T): HttpAgentClass {
return class TimelineHttpAgent extends (BaseAgentClass as any) {
timeline: TimelineEntry[];
/**
* Helper method to log entries to the timeline.
*/
private log(type: 'info' | 'tls' | 'error', message: string): void {
this.timeline.push({
timestamp: new Date(),
type,
message
});
}
constructor(options: HttpAgentOptions, timeline?: TimelineEntry[]) {
const optionsCopy = { ...options };
// For proxy agents, the first argument is the proxy URI and the second is options
if (optionsCopy?.proxy) {
const { proxy: proxyUri, ...agentOptions } = optionsCopy;
super(proxyUri, agentOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
// Log proxy details
this.log('info', `Using proxy: ${proxyUri}`);
} else {
super(optionsCopy);
this.timeline = Array.isArray(timeline) ? timeline : [];
}
}
createConnection(options: any, callback: any) {
const { host, port } = options;
// Capture the current timeline reference to avoid race conditions
// when multiple concurrent requests reuse the same cached agent
const timeline = this.timeline;
const log = (type: 'info' | 'tls' | 'error', message: string): void => {
timeline.push({
timestamp: new Date(),
type,
message
});
};
// Log "Trying host:port..."
log('info', `Trying ${host}:${port}...`);
let socket: any;
try {
socket = super.createConnection(options, callback);
} catch (error: any) {
log('error', `Error creating connection: ${error.message}`);
error.timeline = timeline;
throw error;
}
// Attach event listeners to the socket
socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
if (err) {
log('error', `DNS lookup error for ${host}: ${err.message}`);
} else {
log('info', `DNS lookup: ${host} -> ${address}`);
}
});
socket?.on('connect', () => {
const address = socket.remoteAddress || host;
const remotePort = socket.remotePort || port;
log('info', `Connected to ${host} (${address}) port ${remotePort}`);
});
socket?.on('error', (err: Error) => {
log('error', `Socket error: ${err.message}`);
});
return socket;
}
} as unknown as HttpAgentClass;
}
export { createTimelineAgentClass, createTimelineHttpAgentClass, TimelineEntry, AgentOptions, HttpAgentOptions, CaCertificatesCount, AgentClass, HttpAgentClass, ProxyAgentClass, HttpProxyAgentClass };

View File

@@ -12,4 +12,4 @@ get {
script:pre-request {
bru.runner.stopExecution();
}
}

View File

@@ -11,6 +11,9 @@ get {
}
script:pre-request {
// TODO: skipped because deleteAllCollectionVars does not update the UI
bru.runner.skipRequest();
return;
bru.setCollectionVar("testDelAllCollectionA", "a");
bru.setCollectionVar("testDelAllCollectionB", "b");
}

View File

@@ -11,6 +11,9 @@ get {
}
script:pre-request {
// TODO: skipped because deleteAllGlobalEnvVars does not update the UI
bru.runner.skipRequest();
return;
bru.setGlobalEnvVar("testDelAllGlobalA", "a");
bru.setGlobalEnvVar("testDelAllGlobalB", "b");
}

View File

@@ -11,6 +11,9 @@ get {
}
script:pre-request {
// TODO: skipped because deleteCollectionVar does not update the UI
bru.runner.skipRequest();
return;
bru.setCollectionVar("testDeleteCollectionVar", "to-be-deleted");
bru.deleteCollectionVar("testDeleteCollectionVar");
}

View File

@@ -11,6 +11,9 @@ get {
}
script:pre-request {
// TODO: skipped because deleteGlobalEnvVar does not update the UI
bru.runner.skipRequest();
return;
bru.setGlobalEnvVar("testDeleteGlobalEnvVar", "to-be-deleted");
bru.deleteGlobalEnvVar("testDeleteGlobalEnvVar");
}

View File

@@ -11,6 +11,9 @@ get {
}
script:pre-request {
// TODO: skipped because getAllCollectionVars does not update the UI
bru.runner.skipRequest();
return;
bru.setCollectionVar("testCollectionA", "valueA");
bru.setCollectionVar("testCollectionB", "valueB");
}

View File

@@ -10,6 +10,11 @@ get {
auth: none
}
script:pre-request {
// TODO: skipped because setCollectionVar does not update the UI
bru.runner.skipRequest();
}
script:post-response {
bru.setCollectionVar("testSetCollectionVar", "collection-test-value")
}

View File

@@ -44,6 +44,11 @@ test.describe('Default Location Feature', () => {
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
// Wait for inline creator to appear, then click the cog button to open advanced modal
const inlineCreator = page.locator('.inline-collection-creator');
await inlineCreator.waitFor({ state: 'visible', timeout: 5000 });
await inlineCreator.locator('.cog-btn').click();
// Wait for modal to be visible
await page.locator('.bruno-modal').waitFor({ state: 'visible' });

View File

@@ -68,7 +68,13 @@ const createCollection = async (page, collectionName: string, collectionLocation
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
// Wait for inline creator to appear, then click the cog button to open advanced modal
const inlineCreator = page.locator('.inline-collection-creator');
await inlineCreator.waitFor({ state: 'visible', timeout: 5000 });
await inlineCreator.locator('.cog-btn').click();
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
await createCollectionModal.waitFor({ state: 'visible', timeout: 5000 });
await createCollectionModal.getByLabel('Name').fill(collectionName);
const locationInput = createCollectionModal.getByLabel('Location');