From 3e3e2e0563d62c90777bcb43a0a1034a53ddd0bb Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 19 Aug 2025 22:10:22 +0530 Subject: [PATCH 1/5] feat: persist cookies in app (#5318) --- .../001-cookie-persistence.spec.ts | 43 ++++ .../002-corrupted-passkey.spec.ts | 47 +++++ packages/bruno-electron/src/index.js | 20 +- packages/bruno-electron/src/ipc/collection.js | 24 ++- .../bruno-electron/src/ipc/network/index.js | 3 + packages/bruno-electron/src/store/cookies.js | 192 ++++++++++++++++++ .../bruno-electron/src/utils/encryption.js | 36 ++-- .../tests/cookies-store.test.js | 71 +++++++ playwright/index.ts | 27 ++- 9 files changed, 431 insertions(+), 32 deletions(-) create mode 100644 e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts create mode 100644 e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts create mode 100644 packages/bruno-electron/src/store/cookies.js create mode 100644 packages/bruno-electron/tests/cookies-store.test.js diff --git a/e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts b/e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts new file mode 100644 index 000000000..beefb3b39 --- /dev/null +++ b/e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '../../playwright'; + +test('should persist cookies across app restarts', async ({ createTmpDir, launchElectronApp }) => { + // Create a temporary user-data directory so we control where the cookies store file is written. + const userDataPath = await createTmpDir('cookie-persistence'); + + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.waitForSelector('[data-trigger="cookies"]'); + + // Open Cookies modal via the status-bar button. + await page1.click('[data-trigger="cookies"]'); + + // When no cookies are present the modal shows a centred "Add Cookie" button. + await page1.getByRole('button', { name: /Add Cookie/i }).click(); + + // Fill out the form. + await page1.fill('input[name="domain"]', 'example.com'); + await page1.fill('input[name="path"]', '/'); + await page1.fill('input[name="key"]', 'session'); + await page1.fill('input[name="value"]', 'abc123'); + await page1.check('input[name="secure"]'); + await page1.check('input[name="httpOnly"]'); + + await page1.getByRole('button', { name: 'Save' }).click(); + + await expect(page1.getByText('example.com')).toBeVisible(); + + await app1.close(); + + // Second launch – verify the cookie was persisted and re-loaded + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + + // Open the Cookies modal again. + await page2.waitForSelector('[data-trigger="cookies"]'); + await page2.click('[data-trigger="cookies"]'); + + // The domain we added earlier should still be present. + await expect(page2.getByText('example.com')).toBeVisible(); + + await app2.close(); +}); diff --git a/e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts b/e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts new file mode 100644 index 000000000..02c2a5010 --- /dev/null +++ b/e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '../../playwright'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +test('should handle corrupted passkey and still display saved cookie list', async ({ createTmpDir, launchElectronApp }) => { + const userDataPath = await createTmpDir('corrupted-passkey'); + + const app1 = await launchElectronApp({ userDataPath }); + // 1. First run – add a cookie via the UI so `cookies.json` is created. + const page1 = await app1.firstWindow(); + + await page1.waitForSelector('[data-trigger="cookies"]'); + await page1.click('[data-trigger="cookies"]'); + await page1.getByRole('button', { name: /Add Cookie/i }).click(); + + await page1.fill('input[name="domain"]', 'example.com'); + await page1.fill('input[name="path"]', '/'); + await page1.fill('input[name="key"]', 'session'); + await page1.fill('input[name="value"]', 'abc123'); + await page1.check('input[name="secure"]'); + await page1.check('input[name="httpOnly"]'); + + await page1.getByRole('button', { name: 'Save' }).click(); + + await expect(page1.getByText('example.com')).toBeVisible(); + + await app1.close(); + + // 2. Corrupt the encryptedPasskey in cookies.json + const cookiesFilePath = path.join(userDataPath, 'cookies.json'); + const raw = await fs.readFile(cookiesFilePath, 'utf-8'); + const cookiesJson = JSON.parse(raw); + cookiesJson.encryptedPasskey = 'deadbeef'; // clearly invalid value + await fs.writeFile(cookiesFilePath, JSON.stringify(cookiesJson, null, 2)); + + // 3. Second run – Bruno should recover and still list the cookie domain + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + + await page2.waitForSelector('[data-trigger="cookies"]'); + await page2.click('[data-trigger="cookies"]'); + + // The domain row should still be visible (even if cookie values are blank). + await expect(page2.getByText('example.com')).toBeVisible(); + + await app2.close(); +}); diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 3998e634d..763d4d788 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -32,6 +32,8 @@ const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window') const registerNotificationsIpc = require('./ipc/notifications'); const registerGlobalEnvironmentsIpc = require('./ipc/global-environments'); const { safeParseJSON, safeStringifyJSON } = require('./utils/common'); +const { getDomainsWithCookies } = require('./utils/cookies'); +const { cookiesStore } = require('./store/cookies'); const lastOpenedCollections = new LastOpenedCollections(); @@ -179,7 +181,7 @@ app.on('ready', async () => { mainWindow.minimize(); }); - mainWindow.webContents.on('did-finish-load', () => { + mainWindow.webContents.on('did-finish-load', async () => { let ogSend = mainWindow.webContents.send; mainWindow.webContents.send = function(channel, ...args) { return ogSend.apply(this, [channel, ...args?.map(_ => { @@ -187,6 +189,14 @@ app.on('ready', async () => { return safeParseJSON(safeStringifyJSON(_)); })]); } + // Send cookies list after renderer is ready + try { + cookiesStore.initializeCookies(); + const cookiesList = await getDomainsWithCookies(); + mainWindow.webContents.send('main:cookies-update', cookiesList); + } catch (err) { + console.error('Failed to load cookies for renderer', err); + } }); // register all ipc handlers @@ -198,6 +208,14 @@ app.on('ready', async () => { }); // Quit the app once all windows are closed +app.on('before-quit', () => { + try { + cookiesStore.saveCookieJar(true); + } catch (err) { + console.warn('Failed to flush cookies on quit', err); + } +}); + app.on('window-all-closed', app.quit); // Open collection from Recent menu (#1521) diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 0d51b2f92..ade058d76 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -19,6 +19,7 @@ const { } = require('@usebruno/filestore'); const brunoConverters = require('@usebruno/converters'); const { postmanToBruno } = brunoConverters; +const { cookiesStore } = require('../store/cookies'); const { parseLargeRequestWithRedaction } = require('../utils/parse'); const { @@ -890,12 +891,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); + const updateCookiesAndNotify = async () => { + const domainsWithCookies = await getDomainsWithCookies(); + mainWindow.webContents.send( + 'main:cookies-update', + safeParseJSON(safeStringifyJSON(domainsWithCookies)) + ); + cookiesStore.saveCookieJar(); + }; + + // Delete all cookies for a domain ipcMain.handle('renderer:delete-cookies-for-domain', async (event, domain) => { try { await deleteCookiesForDomain(domain); - - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } @@ -904,8 +913,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:delete-cookie', async (event, domain, path, cookieKey) => { try { await deleteCookie(domain, path, cookieKey); - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } @@ -915,8 +923,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => { try { await addCookieForDomain(domain, cookie); - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } @@ -926,8 +933,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:modify-cookie', async (event, domain, oldCookie, cookie) => { try { await modifyCookieForDomain(domain, oldCookie, cookie); - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 2369f698e..45e14b78c 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -32,6 +32,7 @@ const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); const Oauth2Store = require('../../store/oauth2'); const { isRequestTagsIncluded } = require('@usebruno/common'); +const { cookiesStore } = require('../../store/cookies'); const saveCookies = (url, headers) => { if (preferencesUtil.shouldStoreCookies()) { @@ -771,6 +772,7 @@ const registerNetworkIpc = (mainWindow) => { const domainsWithCookies = await getDomainsWithCookies(); mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + cookiesStore.saveCookieJar(); let postResponseScriptResult = null; let postResponseError = null; @@ -900,6 +902,7 @@ const registerNetworkIpc = (mainWindow) => { const domainsWithCookiesTest = await getDomainsWithCookies(); mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest))); + cookiesStore.saveCookieJar(); } return { diff --git a/packages/bruno-electron/src/store/cookies.js b/packages/bruno-electron/src/store/cookies.js new file mode 100644 index 000000000..c99adda2a --- /dev/null +++ b/packages/bruno-electron/src/store/cookies.js @@ -0,0 +1,192 @@ +const Store = require('electron-store'); +const { cookies: cookiesModule } = require('@usebruno/common'); +const { cookieJar } = cookiesModule; +const { Cookie } = require('tough-cookie'); +const { createCookieString } = cookiesModule; +const crypto = require('crypto'); +const { encryptString, decryptString } = require('../utils/encryption'); + +const DEBOUNCE_MS = 5000; // Debounce duration (ms) for persisting cookie jar +class CookiesStore { + #saveTimerId = null; + #debounceStart = null; // Track first debounce time + #passkey = null; + + constructor() { + this.store = new Store({ + name: 'cookies', + clearInvalidConfig: true, + defaults: { + encryptedPasskey: null, + cookies: {} + } + }); + this.initializeEncryption(); + } + + #generatePasskey() { + // Generate 32 bytes (256 bits) of random data and convert to hex + return crypto.randomBytes(32).toString('hex'); + } + + initializeEncryption() { + try { + let encryptedPasskey = this.store.get('encryptedPasskey'); + if (!encryptedPasskey) { + // Generate cryptographically secure random passkey + const passkey = this.#generatePasskey(); + encryptedPasskey = encryptString(passkey); + if (!encryptedPasskey) { + console.warn('Failed to encrypt new passkey, falling back to unencrypted cookies'); + this.#passkey = null; + return; + } + this.store.set('encryptedPasskey', encryptedPasskey); + } + this.#passkey = decryptString(encryptedPasskey); + if (!this.#passkey) { + console.warn('Failed to decrypt passkey, falling back to unencrypted cookies'); + } + } catch (err) { + console.warn('Failed to initialize encryption, falling back to unencrypted cookies:', err); + this.#passkey = null; + } + } + + getCookies() { + const cookieStore = this.store.get('cookies', {}); + const decryptedCookies = []; + + // Filter and decrypt cookies + Object.values(cookieStore).forEach(domainCookies => { + if (!Array.isArray(domainCookies)) return; + + domainCookies.forEach(cookie => { + try { + + // Create cookie with decrypted value + const decryptedCookie = { + ...cookie, + value: decryptString(cookie.value, this.#passkey) + }; + decryptedCookies.push(decryptedCookie); + } catch (err) { + console.warn('Failed to process cookie:', cookie?.key, err); + // Still add the cookie but with empty value if processing fails + decryptedCookies.push({ + ...cookie, + value: '' + }); + } + }); + }); + + return decryptedCookies; + } + + setCookies(cookies) { + try { + // Organize cookies by domain + const cookiesByDomain = {}; + cookies.cookies.forEach(cookie => { + try { + if (!cookiesByDomain[cookie.domain]) { + cookiesByDomain[cookie.domain] = []; + } + + cookiesByDomain[cookie.domain].push({ + ...cookie, + value: encryptString(cookie.value, this.#passkey) + }); + } catch (err) { + console.warn('Failed to process cookie for storage:', cookie?.key, err); + // Still store the cookie but with original value if encryption fails + if (!cookiesByDomain[cookie.domain]) { + cookiesByDomain[cookie.domain] = []; + } + cookiesByDomain[cookie.domain].push(cookie); + } + }); + + return this.store.set('cookies', cookiesByDomain); + } catch (err) { + console.warn('Failed to set cookies:', err); + } + } + + // Initialize cookies from store into cookie jar + initializeCookies() { + try { + const storedCookies = this.getCookies(); + + if (Array.isArray(storedCookies) && storedCookies.length) { + storedCookies.forEach((cookie) => this.loadCookieIntoJar(cookie)); + } + } catch (err) { + console.warn('Failed to initialize cookies:', err); + } + } + + // Load a single cookie into the cookie jar + loadCookieIntoJar(rawCookie) { + try { + const cookie = Cookie.fromJSON(rawCookie); + if (!cookie) return; + + // Re-assemble request URL for tough-cookie + const protocol = cookie.secure ? 'https' : 'http'; + const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain; + const url = `${protocol}://${domain}${cookie.path || '/'}`; + const setCookieHeader = createCookieString(cookie); + + cookieJar.setCookieSync(setCookieHeader, url, { ignoreError: true }); + } catch (err) { + console.warn('Failed to load cookie:', rawCookie?.key, err?.message); + } + } + + // Save current cookie jar state to store with debouncing + writeCookieJar() { + try { + const serialized = cookieJar.serializeSync(); + this.setCookies(serialized); + + } catch (err) { + console.warn('Failed to save cookie jar:', err); + } finally { + this.#debounceStart = null; + } + } + + saveCookieJar(immediate = false) { + // Debounced write to avoid excessive disk I/O during rapid request bursts + if (immediate) { + if (this.#saveTimerId) { + clearTimeout(this.#saveTimerId); + this.#saveTimerId = null; + } + return this.writeCookieJar(); + } + + if (!this.#debounceStart) { + this.#debounceStart = Date.now(); + } + + if (this.#saveTimerId) { + clearTimeout(this.#saveTimerId); + } + this.#saveTimerId = setTimeout(() => { + this.writeCookieJar(); + this.#saveTimerId = null; + }, DEBOUNCE_MS); + } + +} + +// Create singleton instance +const cookiesStore = new CookiesStore(); + +module.exports = { + cookiesStore, + CookiesStore +}; \ No newline at end of file diff --git a/packages/bruno-electron/src/utils/encryption.js b/packages/bruno-electron/src/utils/encryption.js index 103f92155..54c1a7cc9 100644 --- a/packages/bruno-electron/src/utils/encryption.js +++ b/packages/bruno-electron/src/utils/encryption.js @@ -29,8 +29,8 @@ function deriveKeyAndIv(password, keyLength, ivLength) { return { key, iv }; } -function aes256Encrypt(data) { - const rawKey = machineIdSync(); +function aes256Encrypt(data, passkey = null) { + const rawKey = passkey || machineIdSync(); const iv = Buffer.alloc(16, 0); // Default IV for new encryption const key = crypto.createHash('sha256').update(rawKey).digest(); // Derive a 32-byte key const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); @@ -40,8 +40,8 @@ function aes256Encrypt(data) { return encrypted; } -function aes256Decrypt(data) { - const rawKey = machineIdSync(); +function aes256Decrypt(data, passkey = null) { + const rawKey = passkey || machineIdSync(); // Attempt to decrypt using new method first const iv = Buffer.alloc(16, 0); // Default IV for new encryption @@ -95,7 +95,7 @@ function safeStorageDecrypt(str) { } } -function encryptString(str) { +function encryptString(str, passkey = null) { if (typeof str !== 'string') { throw new Error('Encrypt failed: invalid string'); } @@ -103,20 +103,31 @@ function encryptString(str) { return ''; } - let encryptedString = ''; + // If a passkey is provided (from cookies store), we must use it for encryption. + if (passkey !== null && passkey !== undefined) { + if (typeof passkey !== 'string' || passkey.length === 0) { + // Corrupted / empty passkey -> do not encrypt, return empty value + return ''; + } + try { + const encryptedString = aes256Encrypt(str, passkey); + return `$${AES256_ALGO}:${encryptedString}`; + } catch (err) { + // Any error indicates the passkey is unusable; return empty string + return ''; + } + } if (safeStorage && safeStorage.isEncryptionAvailable()) { - encryptedString = safeStorageEncrypt(str); + const encryptedString = safeStorageEncrypt(str); return `$${ELECTRONSAFESTORAGE_ALGO}:${encryptedString}`; } - // fallback to aes256 - encryptedString = aes256Encrypt(str); - + const encryptedString = aes256Encrypt(str); return `$${AES256_ALGO}:${encryptedString}`; } -function decryptString(str) { +function decryptString(str, passkey = null) { if (typeof str !== 'string') { throw new Error('Decrypt failed: unrecognized string format'); } @@ -148,8 +159,9 @@ function decryptString(str) { } if (algo === AES256_ALGO) { - return aes256Decrypt(encryptedString); + return aes256Decrypt(encryptedString, passkey || null); } + throw new Error('Decrypt failed: Invalid algo'); } function decryptStringSafe(str) { diff --git a/packages/bruno-electron/tests/cookies-store.test.js b/packages/bruno-electron/tests/cookies-store.test.js new file mode 100644 index 000000000..12b859e07 --- /dev/null +++ b/packages/bruno-electron/tests/cookies-store.test.js @@ -0,0 +1,71 @@ +const path = require('path'); + +const mockEncrypt = (str) => (str.length ? `$enc:${str}` : ''); +const mockDecrypt = (str) => str.replace(/^\$enc:/, ''); + +jest.mock('../src/utils/encryption', () => ({ + encryptString: jest.fn(mockEncrypt), + decryptString: jest.fn(mockDecrypt) +})); + +jest.mock('electron-store', () => { + return jest.fn().mockImplementation((opts = {}) => { + const data = { ...(opts.defaults || {}) }; + return { + get: (key, fallback) => (key in data ? data[key] : fallback), + set: (key, value) => { + data[key] = value; + } + }; + }); +}); + +const { CookiesStore } = require(path.join('..', 'src', 'store', 'cookies')); + +function createFreshStore() { + return new CookiesStore(); +} + +describe('CookiesStore', () => { + test('setCookies encrypts values and getCookies returns decrypted values', () => { + const store = createFreshStore(); + + const cookie = { + domain: 'example.com', + key: 'auth', + value: 'token', + path: '/', + secure: true, + httpOnly: true + }; + + store.setCookies({ cookies: [cookie] }); + + // Raw persisted value should be encrypted + const raw = store.store.get('cookies'); + expect(raw['example.com'][0].value).toBe(`$enc:${cookie.value}`); + + // API should return decrypted value + const retrieved = store.getCookies(); + expect(retrieved[0].value).toBe(cookie.value); + }); + + test('getCookies leaves plain-text cookie values untouched', () => { + const store = createFreshStore(); + + const plainCookie = { + domain: 'example.com', + key: 'sid', + value: 'plaintext', + path: '/', + secure: false, + httpOnly: false + }; + + // Manually inject to the underlying store to simulate legacy/plain data + store.store.set('cookies', { 'example.com': [plainCookie] }); + + const cookies = store.getCookies(); + expect(cookies[0].value).toBe('plaintext'); + }); +}); diff --git a/playwright/index.ts b/playwright/index.ts index 549086326..555ea9551 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -14,9 +14,9 @@ export const test = baseTest.extend< }, { createTmpDir: (tag?: string) => Promise; - launchElectronApp: (options?: { initUserDataPath?: string }) => Promise; + launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise; electronApp: ElectronApplication; - reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string }) => Promise; + reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise; } >({ createTmpDir: [ @@ -37,8 +37,13 @@ export const test = baseTest.extend< launchElectronApp: [ async ({ playwright, createTmpDir }, use, workerInfo) => { const apps: ElectronApplication[] = []; - await use(async ({ initUserDataPath } = {}) => { - const userDataPath = await createTmpDir('electron-userdata'); + await use(async ({ initUserDataPath, userDataPath: providedUserDataPath } = {}) => { + const userDataPath = providedUserDataPath || (await createTmpDir('electron-userdata')); + + // Ensure dir exists when caller supplies their own path + if (providedUserDataPath) { + await fs.promises.mkdir(userDataPath, { recursive: true }); + } if (initUserDataPath) { const replacements = { @@ -67,10 +72,10 @@ export const test = baseTest.extend< }); const { workerIndex } = workerInfo; - app.process().stdout.on('data', (data) => { + app.process()?.stdout?.on('data', (data) => { process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); }); - app.process().stderr.on('data', (error) => { + app.process()?.stderr?.on('data', (error) => { process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); }); @@ -137,13 +142,15 @@ export const test = baseTest.extend< reuseOrLaunchElectronApp: [ async ({ launchElectronApp }, use, testInfo) => { const apps: Record = {}; - await use(async ({ initUserDataPath } = {}) => { - const key = initUserDataPath; + await use(async ({ initUserDataPath, userDataPath } = {}) => { + const key = userDataPath || initUserDataPath; if (key && apps[key]) { return apps[key]; } - const app = await launchElectronApp({ initUserDataPath }); - apps[key] = app; + const app = await launchElectronApp({ initUserDataPath, userDataPath }); + if (key) { + apps[key] = app; + } return app; }); }, From e5a608f96265f630d68e0a9cd9c2b2d3bcd90264 Mon Sep 17 00:00:00 2001 From: Sanjai Kumar <161328623+sanjaikumar-bruno@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:05:22 +0530 Subject: [PATCH 2/5] feat: add persistent environment variable handling in IPC events and Bru class (#5172) --- .../001-sanity-tests/001-home-screen.spec.ts | 2 +- .../001-persistent-env-test.spec.ts | 33 +++++++++ .../002-env-test-without-persistence.spec.ts | 40 +++++++++++ .../collection/bruno.json | 5 ++ .../collection/collection.bru | 0 .../collection/environments/Env.bru | 4 ++ .../collection/environments/Stage.bru | 3 + .../collection/persist-env-request.bru | 15 ++++ .../collection/request.bru | 15 ++++ .../init-user-data/preferences.json | 6 ++ .../src/providers/App/useIpcEvents.js | 7 +- .../ReduxStore/slices/collections/actions.js | 62 ++++++++++++++++ .../src/utils/codemirror/autocomplete.js | 3 +- .../bruno-electron/src/ipc/network/index.js | 20 ++++++ packages/bruno-js/src/bru.js | 18 ++++- .../bruno-js/src/runtime/script-runtime.js | 4 ++ packages/bruno-js/src/runtime/test-runtime.js | 1 + packages/bruno-js/src/runtime/vars-runtime.js | 1 + .../bruno-js/src/sandbox/quickjs/shims/bru.js | 4 +- packages/bruno-js/tests/runtime.spec.js | 71 +++++++++++++++++++ playwright/index.ts | 46 ++++++++++-- 21 files changed, 346 insertions(+), 14 deletions(-) create mode 100644 e2e-tests/persistent-env-tests/001-persistent-env-test.spec.ts create mode 100644 e2e-tests/persistent-env-tests/002-env-test-without-persistence.spec.ts create mode 100644 e2e-tests/persistent-env-tests/collection/bruno.json create mode 100644 e2e-tests/persistent-env-tests/collection/collection.bru create mode 100644 e2e-tests/persistent-env-tests/collection/environments/Env.bru create mode 100644 e2e-tests/persistent-env-tests/collection/environments/Stage.bru create mode 100644 e2e-tests/persistent-env-tests/collection/persist-env-request.bru create mode 100644 e2e-tests/persistent-env-tests/collection/request.bru create mode 100644 e2e-tests/persistent-env-tests/init-user-data/preferences.json diff --git a/e2e-tests/001-sanity-tests/001-home-screen.spec.ts b/e2e-tests/001-sanity-tests/001-home-screen.spec.ts index d993fb7bc..326ff895c 100644 --- a/e2e-tests/001-sanity-tests/001-home-screen.spec.ts +++ b/e2e-tests/001-sanity-tests/001-home-screen.spec.ts @@ -2,4 +2,4 @@ import { test, expect } from '../../playwright'; test('Check if the logo on top left is visible', async ({ page }) => { await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible(); -}); \ No newline at end of file +}); diff --git a/e2e-tests/persistent-env-tests/001-persistent-env-test.spec.ts b/e2e-tests/persistent-env-tests/001-persistent-env-test.spec.ts new file mode 100644 index 000000000..e319e178f --- /dev/null +++ b/e2e-tests/persistent-env-tests/001-persistent-env-test.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '../../playwright'; + +test.describe.serial('Persistent Environment Test', () => { + test.setTimeout(2 * 10 * 1000); + + test('add env using script', async ({ pageWithUserData: page, restartApp }) => { + await page.locator('#sidebar-collection-name').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByText('ping', { exact: true }).click(); + await page.getByText('No Environment').click(); + await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click(); + await page.locator('#send-request').getByRole('img').nth(2).click(); + await page.waitForTimeout(1000); + await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click(); + await page.getByText('Configure', { exact: true }).click(); + await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible(); + await page.getByText('×').click(); + + const newApp = await restartApp(); + const newPage = await newApp.firstWindow(); + await newPage.locator('#sidebar-collection-name').click(); + await newPage.getByRole('button', { name: 'Save' }).click(); + await newPage.getByText('ping', { exact: true }).click(); + await newPage.getByText('No Environment').click(); + await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click(); + await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click(); + await newPage.getByText('Configure', { exact: true }).click(); + await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible(); + await newPage.getByText('×').click(); + await newPage.waitForTimeout(1000); + await newPage.close(); + }); +}); diff --git a/e2e-tests/persistent-env-tests/002-env-test-without-persistence.spec.ts b/e2e-tests/persistent-env-tests/002-env-test-without-persistence.spec.ts new file mode 100644 index 000000000..4be151bb0 --- /dev/null +++ b/e2e-tests/persistent-env-tests/002-env-test-without-persistence.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../playwright'; + +test.describe.serial('Persistent Environment Test', () => { + test.setTimeout(2 * 10 * 1000); + + test('add env using script', async ({ pageWithUserData: page, restartApp }) => { + await page.locator('#sidebar-collection-name').click(); + await page.getByText('ping2', { exact: true }).click(); + await page.getByText('Env', { exact: true }).click(); + await page.getByText('Stage', { exact: true }).click(); + await page.locator('#send-request').getByRole('img').nth(2).click(); + await page.waitForTimeout(1000); + await page + .locator('div') + .filter({ hasText: /^Stage$/ }) + .nth(3) + .click(); + await page.getByText('Configure', { exact: true }).click(); + await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible(); + await page.getByText('×').click(); + + const newApp = await restartApp(); + const newPage = await newApp.firstWindow(); + await newPage.locator('#sidebar-collection-name').click(); + await newPage.getByRole('button', { name: 'Save' }).click(); + await newPage.getByText('ping2', { exact: true }).click(); + await newPage.getByText('No Environment').click(); + await newPage.getByText('Stage').click(); + await newPage + .locator('div') + .filter({ hasText: /^Stage$/ }) + .nth(3) + .click(); + await newPage.getByText('Configure', { exact: true }).click(); + await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible(); + await newPage.getByText('×').click(); + await newPage.waitForTimeout(1000); + await newPage.close(); + }); +}); diff --git a/e2e-tests/persistent-env-tests/collection/bruno.json b/e2e-tests/persistent-env-tests/collection/bruno.json new file mode 100644 index 000000000..fa729847c --- /dev/null +++ b/e2e-tests/persistent-env-tests/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "collection", + "type": "collection" +} \ No newline at end of file diff --git a/e2e-tests/persistent-env-tests/collection/collection.bru b/e2e-tests/persistent-env-tests/collection/collection.bru new file mode 100644 index 000000000..e69de29bb diff --git a/e2e-tests/persistent-env-tests/collection/environments/Env.bru b/e2e-tests/persistent-env-tests/collection/environments/Env.bru new file mode 100644 index 000000000..909243fd2 --- /dev/null +++ b/e2e-tests/persistent-env-tests/collection/environments/Env.bru @@ -0,0 +1,4 @@ +vars { + host: https://testbench-sanity.usebruno.com + persistent-env-test: persistent-env-test-value +} diff --git a/e2e-tests/persistent-env-tests/collection/environments/Stage.bru b/e2e-tests/persistent-env-tests/collection/environments/Stage.bru new file mode 100644 index 000000000..0b756aa68 --- /dev/null +++ b/e2e-tests/persistent-env-tests/collection/environments/Stage.bru @@ -0,0 +1,3 @@ +vars { + host: https://testbench-sanity.usebruno.com +} \ No newline at end of file diff --git a/e2e-tests/persistent-env-tests/collection/persist-env-request.bru b/e2e-tests/persistent-env-tests/collection/persist-env-request.bru new file mode 100644 index 000000000..eefb4e827 --- /dev/null +++ b/e2e-tests/persistent-env-tests/collection/persist-env-request.bru @@ -0,0 +1,15 @@ +meta { + name: ping2 + type: http + seq: 1 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + bru.setEnvVar("persistent-env-test", "persistent-env-test-value"); +} \ No newline at end of file diff --git a/e2e-tests/persistent-env-tests/collection/request.bru b/e2e-tests/persistent-env-tests/collection/request.bru new file mode 100644 index 000000000..9ae6899c5 --- /dev/null +++ b/e2e-tests/persistent-env-tests/collection/request.bru @@ -0,0 +1,15 @@ +meta { + name: ping + type: http + seq: 1 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + bru.setEnvVar("persistent-env-test", "persistent-env-test-value", { persist: true }); +} \ No newline at end of file diff --git a/e2e-tests/persistent-env-tests/init-user-data/preferences.json b/e2e-tests/persistent-env-tests/init-user-data/preferences.json new file mode 100644 index 000000000..f9c1fdc7e --- /dev/null +++ b/e2e-tests/persistent-env-tests/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": true, + "lastOpenedCollections": [ + "{{projectRoot}}/e2e-tests/persistent-env-tests/collection" + ] +} \ No newline at end of file diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 34b3e3d5d..3ad176bd4 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -19,7 +19,7 @@ import { runRequestEvent, scriptEnvironmentUpdateEvent } from 'providers/ReduxStore/slices/collections'; -import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot } from 'providers/ReduxStore/slices/collections/actions'; +import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import toast from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; @@ -112,6 +112,10 @@ const useIpcEvents = () => { dispatch(scriptEnvironmentUpdateEvent(val)); }); + const removePersistentEnvVariablesUpdateListener = ipcRenderer.on('main:persistent-env-variables-update', (val) => { + dispatch(mergeAndPersistEnvironment(val)); + }); + const removeGlobalEnvironmentVariablesUpdateListener = ipcRenderer.on('main:global-environment-variables-update', (val) => { dispatch(globalEnvironmentsUpdateEvent(val)); }); @@ -204,6 +208,7 @@ const useIpcEvents = () => { removeSnapshotHydrationListener(); removeCollectionOauth2CredentialsUpdatesListener(); removeCollectionLoadingStateListener(); + removePersistentEnvVariablesUpdateListener(); }; }, [isElectron]); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 7e3a758d3..e581bfc97 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1050,6 +1050,68 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di }); }; +export const mergeAndPersistEnvironment = + ({ persistentEnvVariables, collectionUid }) => + (_dispatch, getState) => { + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + + if (!collection) { + return reject(new Error('Collection not found')); + } + + const environmentUid = collection.activeEnvironmentUid; + if (!environmentUid) { + return reject(new Error('No active environment found')); + } + + const collectionCopy = cloneDeep(collection); + const environment = findEnvironmentInCollection(collectionCopy, environmentUid); + if (!environment) { + return reject(new Error('Environment not found')); + } + + // Only proceed if there are persistent variables to save + if (!persistentEnvVariables || Object.keys(persistentEnvVariables).length === 0) { + return resolve(); + } + + let existingVars = environment.variables || []; + + let normalizedNewVars = Object.entries(persistentEnvVariables).map(([name, value]) => ({ + uid: uuid(), + name, + value, + type: 'text', + enabled: true, + secret: false + })); + + const merged = existingVars.map((v) => { + const found = normalizedNewVars.find((nv) => nv.name === v.name); + if (found) { + return { ...v, value: found.value }; + } + return v; + }); + normalizedNewVars.forEach((nv) => { + if (!merged.some((v) => v.name === nv.name)) { + merged.push(nv); + } + }); + + environment.variables = merged; + + const { ipcRenderer } = window; + environmentSchema + .validate(environment) + .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment)) + .then(resolve) + .catch(reject); + }); + }; + export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, getState) => { return new Promise((resolve, reject) => { const state = getState(); diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js index c2d1ba9ba..c105caa63 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js @@ -60,7 +60,8 @@ const STATIC_API_HINTS = { 'bru.getEnvVar(key)', 'bru.getFolderVar(key)', 'bru.getCollectionVar(key)', - 'bru.setEnvVar(key,value)', + 'bru.setEnvVar(key, value)', + 'bru.setEnvVar(key, value, options)', 'bru.deleteEnvVar(key)', 'bru.hasVar(key)', 'bru.getVar(key)', diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 45e14b78c..40bbd7d5f 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -468,6 +468,11 @@ const registerNetworkIpc = (mainWindow) => { collectionUid }); + mainWindow.webContents.send('main:persistent-env-variables-update', { + persistentEnvVariables: scriptResult.persistentEnvVariables, + collectionUid + }); + mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: scriptResult.globalEnvironmentVariables }); @@ -542,6 +547,11 @@ const registerNetworkIpc = (mainWindow) => { collectionUid }); + mainWindow.webContents.send('main:persistent-env-variables-update', { + persistentEnvVariables: result.persistentEnvVariables, + collectionUid + }); + mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: result.globalEnvironmentVariables }); @@ -583,6 +593,11 @@ const registerNetworkIpc = (mainWindow) => { collectionUid }); + mainWindow.webContents.send('main:persistent-env-variables-update', { + persistentEnvVariables: scriptResult.persistentEnvVariables, + collectionUid + }); + mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: scriptResult.globalEnvironmentVariables }); @@ -887,6 +902,11 @@ const registerNetworkIpc = (mainWindow) => { collectionUid }); + mainWindow.webContents.send('main:persistent-env-variables-update', { + persistentEnvVariables: testResults.persistentEnvVariables, + collectionUid + }); + mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: testResults.globalEnvironmentVariables }); diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js index c00ac9b55..c717166d6 100644 --- a/packages/bruno-js/src/bru.js +++ b/packages/bruno-js/src/bru.js @@ -18,7 +18,6 @@ class Bru { this.collectionPath = collectionPath; this.collectionName = collectionName; this.sendRequest = sendRequest; - this.cookies = { jar: () => { const cookieJar = createCookieJar(); @@ -62,6 +61,8 @@ class Bru { }; } }; + // Holds variables that are marked as persistent by scripts + this.persistentEnvVariables = {}; this.runner = { skipRequest: () => { this.skipRequest = true; @@ -119,12 +120,25 @@ class Bru { return this.interpolate(this.envVariables[key]); } - setEnvVar(key, value) { + setEnvVar(key, value, options = {}) { if (!key) { throw new Error('Creating a env variable without specifying a name is not allowed.'); } + // When persist is true, only string values are allowed + if (options?.persist && typeof value !== 'string') { + throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`); + } + this.envVariables[key] = value; + + if (options?.persist) { + this.persistentEnvVariables[key] = value + } else { + if (this.persistentEnvVariables[key]) { + delete this.persistentEnvVariables[key]; + } + } } deleteEnvVar(key) { diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 2f3e8ef9f..7a9b1a5bc 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -122,6 +122,7 @@ class ScriptRuntime { request, envVariables: cleanJson(envVariables), runtimeVariables: cleanJson(runtimeVariables), + persistentEnvVariables: bru.persistentEnvVariables, globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), results: cleanJson(__brunoTestResults.getResults()), nextRequestName: bru.nextRequest, @@ -177,6 +178,7 @@ class ScriptRuntime { request, envVariables: cleanJson(envVariables), runtimeVariables: cleanJson(runtimeVariables), + persistentEnvVariables: bru.persistentEnvVariables, globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), results: cleanJson(__brunoTestResults.getResults()), nextRequestName: bru.nextRequest, @@ -268,6 +270,7 @@ class ScriptRuntime { return { response, envVariables: cleanJson(envVariables), + persistentEnvVariables: cleanJson(bru.persistentEnvVariables), runtimeVariables: cleanJson(runtimeVariables), globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), results: cleanJson(__brunoTestResults.getResults()), @@ -323,6 +326,7 @@ class ScriptRuntime { return { response, envVariables: cleanJson(envVariables), + persistentEnvVariables: cleanJson(bru.persistentEnvVariables), runtimeVariables: cleanJson(runtimeVariables), globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), results: cleanJson(__brunoTestResults.getResults()), diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index 2088a5cb7..ae4e2c963 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -184,6 +184,7 @@ class TestRuntime { envVariables: cleanJson(envVariables), runtimeVariables: cleanJson(runtimeVariables), globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), + persistentEnvVariables: cleanJson(bru.persistentEnvVariables), results: cleanJson(__brunoTestResults.getResults()), nextRequestName: bru.nextRequest }; diff --git a/packages/bruno-js/src/runtime/vars-runtime.js b/packages/bruno-js/src/runtime/vars-runtime.js index fba98e0fe..05f502c2d 100644 --- a/packages/bruno-js/src/runtime/vars-runtime.js +++ b/packages/bruno-js/src/runtime/vars-runtime.js @@ -74,6 +74,7 @@ class VarsRuntime { envVariables, runtimeVariables, globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), + persistentEnvVariables: cleanJson(bru.persistentEnvVariables), error }; } diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js index d99aec94b..4a67a80f8 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js @@ -47,8 +47,8 @@ const addBruShimToContext = (vm, bru) => { vm.setProp(bruObject, 'getEnvVar', getEnvVar); getEnvVar.dispose(); - let setEnvVar = vm.newFunction('setEnvVar', function (key, value) { - bru.setEnvVar(vm.dump(key), vm.dump(value)); + let setEnvVar = vm.newFunction('setEnvVar', function (key, value, options = {}) { + bru.setEnvVar(vm.dump(key), vm.dump(value), vm.dump(options)); }); vm.setProp(bruObject, 'setEnvVar', setEnvVar); setEnvVar.dispose(); diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js index 766569d03..64f0d2589 100644 --- a/packages/bruno-js/tests/runtime.spec.js +++ b/packages/bruno-js/tests/runtime.spec.js @@ -1,6 +1,8 @@ const { describe, it, expect } = require('@jest/globals'); const TestRuntime = require('../src/runtime/test-runtime'); const ScriptRuntime = require('../src/runtime/script-runtime'); +const Bru = require('../src/bru'); +const VarsRuntime = require('../src/runtime/vars-runtime'); describe('runtime', () => { describe('test-runtime', () => { @@ -175,4 +177,73 @@ describe('runtime', () => { }); }); }); + + describe('persistent environment variables validation', () => { + it('should throw error when trying to persist non-string values', async () => { + const script = `bru.setEnvVar('number', 42, {persist: true});`; + const runtime = new ScriptRuntime({ runtime: 'vm2' }); + + await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env)) + .rejects.toThrow('Persistent environment variables must be strings. Received number for key "number".'); + }); + + it('should throw error when trying to persist boolean values', async () => { + const script = `bru.setEnvVar('isActive', true, {persist: true});`; + const runtime = new ScriptRuntime({ runtime: 'vm2' }); + + await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env)) + .rejects.toThrow('Persistent environment variables must be strings. Received boolean for key "isActive".'); + }); + + it('should throw error when trying to persist object values', async () => { + const script = `bru.setEnvVar('config', {port: 3000}, {persist: true});`; + const runtime = new ScriptRuntime({ runtime: 'vm2' }); + + await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env)) + .rejects.toThrow('Persistent environment variables must be strings. Received object for key "config".'); + }); + + it('should throw error when trying to persist array values', async () => { + const script = `bru.setEnvVar('items', ['item1', 'item2'], {persist: true});`; + const runtime = new ScriptRuntime({ runtime: 'vm2' }); + + await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env)) + .rejects.toThrow('Persistent environment variables must be strings. Received object for key "items".'); + }); + + it('should allow string values when persist is true', async () => { + const script = `bru.setEnvVar('api_key', 'abc123', {persist: true});`; + const runtime = new ScriptRuntime({ runtime: 'vm2' }); + + const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env); + + expect(result.envVariables.api_key).toBe('abc123'); + }); + + it('should allow non-string values when persist is false', async () => { + const script = ` + bru.setEnvVar('number', 42, {persist: false}); + bru.setEnvVar('boolean', true, {persist: false}); + bru.setEnvVar('object', {key: 'value'}, {persist: false}); + bru.setEnvVar('array', [1, 2, 3], {persist: false}); + `; + const runtime = new ScriptRuntime({ runtime: 'vm2' }); + + const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env); + + expect(result.envVariables.number).toBe(42); + expect(result.envVariables.boolean).toBe(true); + expect(result.envVariables.object).toEqual({key: 'value'}); + expect(result.envVariables.array).toEqual([1, 2, 3]); + }); + + it('should allow non-string values when persist is not specified', async () => { + const script = `bru.setEnvVar('number', 42);`; + const runtime = new ScriptRuntime({ runtime: 'vm2' }); + + const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env); + + expect(result.envVariables.number).toBe(42); + }); + }); }); diff --git a/playwright/index.ts b/playwright/index.ts index 555ea9551..a17809da4 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -11,6 +11,7 @@ export const test = baseTest.extend< page: Page; newPage: Page; pageWithUserData: Page; + restartApp: (options?: { initUserDataPath?: string }) => Promise; }, { createTmpDir: (tag?: string) => Promise; @@ -67,17 +68,22 @@ export const test = baseTest.extend< args: [electronAppPath], env: { ...process.env, - ELECTRON_USER_DATA_PATH: userDataPath, + ELECTRON_USER_DATA_PATH: userDataPath } }); const { workerIndex } = workerInfo; - app.process()?.stdout?.on('data', (data) => { - process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); - }); - app.process()?.stderr?.on('data', (error) => { - process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); - }); + const electronProcess = app.process(); + if (electronProcess?.stdout) { + electronProcess.stdout.on('data', (data) => { + process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); + }); + } + if (electronProcess?.stderr) { + electronProcess.stderr.on('data', (error) => { + process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); + }); + } apps.push(app); return app; @@ -157,6 +163,32 @@ export const test = baseTest.extend< { scope: 'worker' } ], + restartApp: async ({ launchElectronApp }, use, testInfo) => { + const appInstances: Array<{ app: ElectronApplication; initUserDataPath?: string }> = []; + await use(async ({ initUserDataPath } = {}) => { + // Get the test directory and check for init-user-data folder + const testDir = path.dirname(testInfo.file); + const defaultInitUserDataPath = path.join(testDir, 'init-user-data'); + + // Use provided initUserDataPath, or check if default path exists, or use undefined + let userDataPath = initUserDataPath; + if (!userDataPath) { + const hasInitUserData = await fs.promises.stat(defaultInitUserDataPath).catch(() => false); + userDataPath = hasInitUserData ? defaultInitUserDataPath : undefined; + } + + const app = await launchElectronApp({ initUserDataPath: userDataPath }); + appInstances.push({ app, initUserDataPath: userDataPath }); + return app; + }); + + // Clean up all app instances + for (const { app } of appInstances) { + await app.context().close(); + await app.close(); + } + }, + pageWithUserData: async ({ reuseOrLaunchElectronApp }, use, testInfo) => { const testDir = path.dirname(testInfo.file); const initUserDataPath = path.join(testDir, 'init-user-data'); From efb2e83ad9281a7e54b446290af4de564c76fbd2 Mon Sep 17 00:00:00 2001 From: sanish chirayath Date: Wed, 20 Aug 2025 16:24:49 +0530 Subject: [PATCH 3/5] Add gRPC support (#5148) --- package-lock.json | 266 ++++- package.json | 1 + .../ClientCertSettings/StyledWrapper.js | 42 + .../ClientCertSettings/index.js | 5 +- .../CollectionSettings/Grpc/StyledWrapper.js | 13 + .../CollectionSettings/Grpc/index.js | 263 +++++ .../CollectionSettings/Presets/index.js | 31 +- .../components/CollectionSettings/index.js | 18 +- .../src/components/Dropdown/StyledWrapper.js | 1 + .../src/components/Dropdown/index.js | 3 +- .../src/components/Icons/Grpc/index.js | 93 ++ .../Preferences/Beta/StyledWrapper.js | 35 + .../src/components/Preferences/Beta/index.js | 125 ++ .../src/components/Preferences/index.js | 10 +- .../RequestPane/GrpcBody/StyledWrapper.js | 59 + .../components/RequestPane/GrpcBody/index.js | 354 ++++++ .../RequestPane/GrpcQueryUrl/StyledWrapper.js | 119 ++ .../RequestPane/GrpcQueryUrl/index.js | 1028 +++++++++++++++++ .../GrpcAuth/GrpcAuthMode/index.js | 85 ++ .../GrpcRequestPane/GrpcAuth/StyledWrapper.js | 9 + .../GrpcRequestPane/GrpcAuth/index.js | 125 ++ .../GrpcRequestPane/StyledWrapper.js | 34 + .../RequestPane/GrpcRequestPane/index.js | 124 ++ .../components/RequestPane/QueryUrl/index.js | 10 +- .../RequestBody/RequestBodyMode/index.js | 150 +-- .../RequestPane/RequestBody/index.js | 2 +- .../RequestPane/RequestHeaders/index.js | 4 +- .../RequestTabPanel/StyledWrapper.js | 2 + .../src/components/RequestTabPanel/index.js | 55 +- .../RequestTabs/RequestTab/index.js | 8 +- .../GrpcError/StyledWrapper.js | 44 + .../GrpcResponsePane/GrpcError/index.js | 23 + .../GrpcQueryResult/StyledWrapper.js | 96 ++ .../GrpcResponsePane/GrpcQueryResult/index.js | 126 ++ .../GrpcResponseHeaders/StyledWrapper.js | 31 + .../GrpcResponseHeaders/index.js | 38 + .../GrpcStatusCode/StyledWrapper.js | 22 + .../get-grpc-status-code-phrase.js | 22 + .../GrpcResponsePane/GrpcStatusCode/index.js | 27 + .../ResponseTrailers/StyledWrapper.js | 31 + .../ResponseTrailers/index.js | 37 + .../GrpcResponsePane/StyledWrapper.js | 58 + .../ResponsePane/GrpcResponsePane/index.js | 160 +++ .../ResponsePane/ResponseSize/index.js | 2 +- .../ResponsePane/ResponseTime/index.js | 6 +- .../Timeline/GrpcTimelineItem/index.js | 274 +++++ .../ResponsePane/Timeline/StyledWrapper.js | 9 + .../Timeline/TimelineItem/index.js | 2 +- .../components/ResponsePane/Timeline/index.js | 98 +- .../RunnerResults/ResponsePane/index.js | 1 - .../src/components/ShareCollection/index.js | 85 +- .../RequestMethod/StyledWrapper.js | 3 + .../CollectionItem/RequestMethod/index.js | 9 +- .../components/Sidebar/NewRequest/index.js | 145 ++- .../bruno-app/src/components/Tab/index.js | 22 + .../src/components/ToggleSwitch/index.js | 4 +- packages/bruno-app/src/providers/App/index.js | 3 +- .../bruno-app/src/providers/Hotkeys/index.js | 13 + .../src/providers/ReduxStore/slices/app.js | 3 + .../ReduxStore/slices/collections/actions.js | 939 +++++++++------ .../ReduxStore/slices/collections/index.js | 231 +++- .../src/providers/ReduxStore/slices/tabs.js | 12 +- packages/bruno-app/src/themes/dark.js | 6 +- packages/bruno-app/src/themes/light.js | 4 +- packages/bruno-app/src/utils/beta-features.js | 19 + .../bruno-app/src/utils/collections/export.js | 8 +- .../bruno-app/src/utils/collections/index.js | 60 +- packages/bruno-app/src/utils/common/path.js | 28 + packages/bruno-app/src/utils/filesystem.js | 33 + .../bruno-app/src/utils/importers/common.js | 3 +- .../src/utils/network/grpc-event-listeners.js | 127 ++ packages/bruno-app/src/utils/network/index.js | 157 ++- packages/bruno-app/src/utils/tabs/index.js | 2 +- packages/bruno-cli/src/utils/bru.js | 51 +- packages/bruno-converters/src/common/index.js | 2 +- .../src/postman/bruno-to-postman.js | 4 + packages/bruno-electron/package.json | 4 +- packages/bruno-electron/src/index.js | 2 + packages/bruno-electron/src/ipc/collection.js | 24 +- packages/bruno-electron/src/ipc/filesystem.js | 53 + .../src/ipc/network/cert-utils.js | 114 ++ .../src/ipc/network/grpc-event-handlers.js | 574 +++++++++ .../bruno-electron/src/ipc/network/index.js | 131 +-- .../src/ipc/network/interpolate-vars.js | 10 +- packages/bruno-electron/src/preload.js | 4 +- .../bruno-electron/src/store/preferences.js | 9 + .../bruno-electron/src/utils/collection.js | 37 +- .../tests/network/interpolate-vars.spec.js | 56 + .../bruno-filestore/src/formats/bru/index.ts | 147 ++- packages/bruno-js/package.json | 6 +- packages/bruno-lang/v2/src/bruToJson.js | 60 +- packages/bruno-lang/v2/src/jsonToBru.js | 80 +- packages/bruno-lang/v2/src/utils.js | 2 +- packages/bruno-requests/package.json | 17 +- packages/bruno-requests/rollup.config.js | 12 +- .../src/auth/digestauth-helper.js | 2 +- .../bruno-requests/src/grpc/grpc-client.js | 845 ++++++++++++++ .../src/grpc/grpcMessageGenerator.js | 132 +++ packages/bruno-requests/src/grpc/index.ts | 2 + packages/bruno-requests/src/index.ts | 3 +- .../bruno-schema/src/collections/index.js | 48 +- .../src/collections/index.spec.js | 69 ++ 102 files changed, 7756 insertions(+), 841 deletions(-) create mode 100644 packages/bruno-app/src/components/CollectionSettings/Grpc/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/CollectionSettings/Grpc/index.js create mode 100644 packages/bruno-app/src/components/Icons/Grpc/index.js create mode 100644 packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Preferences/Beta/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcBody/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcBody/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/get-grpc-status-code-phrase.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js create mode 100644 packages/bruno-app/src/components/Tab/index.js create mode 100644 packages/bruno-app/src/utils/beta-features.js create mode 100644 packages/bruno-app/src/utils/filesystem.js create mode 100644 packages/bruno-app/src/utils/network/grpc-event-listeners.js create mode 100644 packages/bruno-electron/src/ipc/filesystem.js create mode 100644 packages/bruno-electron/src/ipc/network/cert-utils.js create mode 100644 packages/bruno-electron/src/ipc/network/grpc-event-handlers.js create mode 100644 packages/bruno-requests/src/grpc/grpc-client.js create mode 100644 packages/bruno-requests/src/grpc/grpcMessageGenerator.js create mode 100644 packages/bruno-requests/src/grpc/index.ts diff --git a/package-lock.json b/package-lock.json index fcef3b196..fafb51163 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@faker-js/faker": "^7.6.0", "@jest/globals": "^29.2.0", "@playwright/test": "^1.51.1", + "@rollup/plugin-json": "^6.1.0", "@types/jest": "^29.5.11", "@types/lodash-es": "^4.17.12", "@types/node": "^22.14.1", @@ -4490,6 +4491,37 @@ } } }, + "node_modules/@grpc/grpc-js": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz", + "integrity": "sha512-FTXHdOoPbZrBjlVLHuKbDZnsTxXv2BlHF57xw6LuThXacXvtkahEPED0CKMk6obZDf65Hv4k3z62eyPNpvinIg==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@headlessui/react": { "version": "1.7.19", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", @@ -5274,6 +5306,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", @@ -6360,6 +6402,70 @@ "node": ">=16.9" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", @@ -7053,6 +7159,27 @@ } } }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", @@ -8393,6 +8520,12 @@ "@types/node": "*" } }, + "node_modules/@types/google-protobuf": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.15.12.tgz", + "integrity": "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -8543,6 +8676,15 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.set": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@types/lodash.set/-/lodash.set-4.3.9.tgz", + "integrity": "sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/markdown-it": { "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", @@ -8572,7 +8714,6 @@ "version": "22.15.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -10646,6 +10787,19 @@ "dev": true, "license": "MIT" }, + "node_modules/builtin-modules": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", + "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -13723,15 +13877,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emitter-component": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz", - "integrity": "sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -15928,6 +16073,12 @@ "csstype": "^3.0.10" } }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -16042,6 +16193,22 @@ "node": ">= 6" } }, + "node_modules/grpc-reflection-js": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/grpc-reflection-js/-/grpc-reflection-js-0.3.0.tgz", + "integrity": "sha512-3lhTlQluPxVgbowCXA3tAZC3RJW+GSOUkguLNYl1QffYRiutUB3RDfPkQFTcrCFJgNiIIxx+iJkr8s3uSp3zWA==", + "license": "MIT", + "dependencies": { + "@types/google-protobuf": "^3.7.2", + "@types/lodash.set": "^4.3.6", + "google-protobuf": "^3.12.2", + "lodash.set": "^4.3.2", + "protobufjs": "^7.2.2" + }, + "peerDependencies": { + "@grpc/grpc-js": "^1.0.0" + } + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -19498,7 +19665,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.curry": { @@ -19576,6 +19742,12 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==", + "license": "MIT" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -19617,6 +19789,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -22958,6 +23136,30 @@ "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -25818,16 +26020,6 @@ "node": ">= 0.4" } }, - "node_modules/stream": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", - "integrity": "sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emitter-component": "^1.1.1" - } - }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -27261,7 +27453,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -31790,6 +31981,8 @@ "version": "2.0.0", "dependencies": { "@aws-sdk/credential-providers": "3.750.0", + "@grpc/grpc-js": "^1.13.2", + "@grpc/proto-loader": "^0.7.13", "@usebruno/common": "0.1.0", "@usebruno/converters": "^0.1.0", "@usebruno/filestore": "^0.1.0", @@ -33098,9 +33291,7 @@ "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", "rollup": "3.29.5", - "rollup-plugin-terser": "^7.0.2", - "stream": "^0.0.2", - "util": "^0.12.5" + "rollup-plugin-terser": "^7.0.2" }, "peerDependencies": { "@usebruno/vm2": "^3.9.13" @@ -33166,17 +33357,24 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@faker-js/faker": "^9.7.0", + "@grpc/grpc-js": "^1.13.3", + "@grpc/proto-loader": "^0.7.15", "@types/qs": "^6.9.18", - "axios": "^1.9.0" + "axios": "^1.9.0", + "grpc-reflection-js": "^0.3.0" }, "devDependencies": { "@babel/preset-env": "^7.22.0", "@babel/preset-typescript": "^7.22.0", + "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^23.0.2", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-typescript": "^9.0.2", "@types/jest": "^29.5.11", "babel-jest": "^29.7.0", + "builtin-modules": "^5.0.0", "jest": "^29.2.0", "rollup": "3.29.5", "rollup-plugin-dts": "^5.0.0", @@ -33185,6 +33383,22 @@ "typescript": "^4.8.4" } }, + "packages/bruno-requests/node_modules/@faker-js/faker": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz", + "integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, "packages/bruno-requests/node_modules/axios": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", diff --git a/package.json b/package.json index a71957ed1..aa01a08f2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@faker-js/faker": "^7.6.0", "@jest/globals": "^29.2.0", "@playwright/test": "^1.51.1", + "@rollup/plugin-json": "^6.1.0", "@types/jest": "^29.5.11", "@types/lodash-es": "^4.17.12", "@types/node": "^22.14.1", diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js index 621b2b86b..affe1f7db 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js @@ -38,6 +38,48 @@ const StyledWrapper = styled.div` outline: none !important; } } + + .protocol-placeholder { + height: 100%; + position: relative; + display: inline-block; + width: 60px; + overflow: hidden; + } + + .protocol-https, + .protocol-grpcs { + position: absolute; + right: 8px; + top: 0; + bottom: 0; + transition: transform 0.3s ease-in-out; + display: flex; + align-items: center; + justify-content: center; + } + + .protocol-https { + animation: slideUpDown 6s infinite; + transform: translateY(0); + } + + .protocol-grpcs { + animation: slideUpDown 6s infinite 3s; + transform: translateY(100%); + } + + @keyframes slideUpDown { + 0%, 45% { + transform: translateY(0); + } + 50%, 95% { + transform: translateY(100%); + } + 100% { + transform: translateY(0); + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js index 7ae9ac85e..3f62fc23e 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js @@ -132,7 +132,10 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
- https:// + + https:// + grpcs:// +
props.theme.requestTabPanel.url.bg}; + + button.remove-certificate { + color: ${(props) => props.theme.colors.text.danger}; + } + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/CollectionSettings/Grpc/index.js b/packages/bruno-app/src/components/CollectionSettings/Grpc/index.js new file mode 100644 index 000000000..db2313efd --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Grpc/index.js @@ -0,0 +1,263 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useFormik } from 'formik'; +import { useDispatch } from 'react-redux'; +import StyledWrapper from './StyledWrapper'; +import toast from 'react-hot-toast'; +import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions'; +import cloneDeep from 'lodash/cloneDeep'; +import { IconTrash, IconFile, IconFileImport, IconAlertCircle } from '@tabler/icons'; +import { getRelativePath, getBasename, getDirPath } from 'utils/common/path'; +import { Tooltip } from 'react-tooltip'; +import { existsSync, resolvePath } from '../../../utils/filesystem'; + +const GrpcSettings = ({ collection }) => { + const dispatch = useDispatch(); + const { + brunoConfig: { grpc: grpcConfig = {} } + } = collection; + + const fileInputRef = useRef(null); + const [protoFileValidity, setProtoFileValidity] = useState({}); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + protoFiles: grpcConfig.protoFiles || [] + }, + onSubmit: (newGrpcConfig) => { + const brunoConfig = cloneDeep(collection.brunoConfig); + brunoConfig.grpc = newGrpcConfig; + dispatch(updateBrunoConfig(brunoConfig, collection.uid)); + toast.success('gRPC settings updated'); + } + }); + + // Get file path using the ipcRenderer + const getProtoFile = (event) => { + const files = event?.files; + if (files && files.length > 0) { + const newProtoFiles = [...formik.values.protoFiles]; + + for (let i = 0; i < files.length; i++) { + const filePath = window?.ipcRenderer?.getFilePath(files[i]); + if (filePath) { + const relativePath = getRelativePath(filePath, collection.pathname); + const protoFileObj = { + path: relativePath, + type: 'file' + }; + + // Check if this path already exists + const exists = newProtoFiles.some(pf => pf.path === protoFileObj.path); + if (!exists) { + newProtoFiles.push(protoFileObj); + } + } + } + + formik.setFieldValue('protoFiles', newProtoFiles); + // Reset the file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + // Handler for removing a proto file + const handleRemoveProtoFile = (index) => { + const updatedProtoFiles = [...formik.values.protoFiles]; + updatedProtoFiles.splice(index, 1); + formik.setFieldValue('protoFiles', updatedProtoFiles); + }; + + // Handle the browse button click + const handleBrowseClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + // Check if a proto file path is valid + const isProtoFileValid = async (protoFile) => { + try { + const absolutePath = await resolvePath(protoFile.path, collection.pathname); + return await existsSync(absolutePath); + } catch (error) { + return false; + } + }; + + // Validate all proto files and update state + useEffect(() => { + const validateProtoFiles = async () => { + const validityMap = {}; + for (const file of formik.values.protoFiles) { + validityMap[file.path] = await isProtoFileValid(file); + } + setProtoFileValidity(validityMap); + }; + + validateProtoFiles(); + }, [formik.values.protoFiles, collection.pathname]); + + // Handle replacing an invalid proto file + const handleReplaceProtoFile = (index) => { + if (fileInputRef.current) { + fileInputRef.current.click(); + // Store the index to replace after file selection + fileInputRef.current.dataset.replaceIndex = index; + } + }; + + // Handle file input change + const handleFileInputChange = (e) => { + const replaceIndex = e.target.dataset.replaceIndex; + if (replaceIndex !== undefined) { + // Handle replacement + const files = e.target.files; + if (files && files.length > 0) { + const filePath = window?.ipcRenderer?.getFilePath(files[0]); + if (filePath) { + const relativePath = getRelativePath(filePath, collection.pathname); + const updatedProtoFiles = [...formik.values.protoFiles]; + updatedProtoFiles[replaceIndex] = { + path: relativePath, + type: 'file' + }; + formik.setFieldValue('protoFiles', updatedProtoFiles); + } + } + delete e.target.dataset.replaceIndex; + } else { + getProtoFile(e.target); + } + }; + + return ( + +
+
+ +
+ {/* Hidden file input for file selection */} + + +
+ {/* File selection options */} +
+
+ +
+
+ + {/* Divider */} +
+ + {/* List of added proto files */} +
+
+ + Added Proto Files ({formik.values.protoFiles.length}) +
+ + {formik.values.protoFiles.length === 0 ? ( +
No proto files added yet
+ ) : ( + <> + {formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && ( +
+ + Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations. +
+ )} +
    + {formik.values.protoFiles.map((file, index) => { + const isValid = protoFileValidity[file.path]; + return ( +
  • +
    +
    + +
    + {getBasename(file.path)} + + {getDirPath(file.path)} + +
    +
    +
    + {!isValid && ( +
    + + +
    + )} + +
    +
    +
  • + ); + })} +
+ + )} +
+
+
+
+ +
+ +
+
+
+ ); +}; + +export default GrpcSettings; \ No newline at end of file diff --git a/packages/bruno-app/src/components/CollectionSettings/Presets/index.js b/packages/bruno-app/src/components/CollectionSettings/Presets/index.js index e16884e16..8683fa4f9 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Presets/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Presets/index.js @@ -1,13 +1,15 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { useFormik } from 'formik'; import { useDispatch } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions'; import cloneDeep from 'lodash/cloneDeep'; +import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; const PresetsSettings = ({ collection }) => { const dispatch = useDispatch(); + const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC); const { brunoConfig: { presets: presets = {} } } = collection; @@ -15,10 +17,15 @@ const PresetsSettings = ({ collection }) => { const formik = useFormik({ enableReinitialize: true, initialValues: { - requestType: presets.requestType || 'http', + requestType: presets.requestType === 'grpc' && !isGrpcEnabled ? 'http' : presets.requestType || 'http', requestUrl: presets.requestUrl || '' }, onSubmit: (newPresets) => { + // If gRPC is disabled but the preset is set to grpc, change it to http + if (!isGrpcEnabled && newPresets.requestType === 'grpc') { + newPresets.requestType = 'http'; + } + const brunoConfig = cloneDeep(collection.brunoConfig); brunoConfig.presets = newPresets; dispatch(updateBrunoConfig(brunoConfig, collection.uid)); @@ -62,6 +69,23 @@ const PresetsSettings = ({ collection }) => { + + {isGrpcEnabled && ( + <> + + + + )}
@@ -74,7 +98,7 @@ const PresetsSettings = ({ collection }) => { id="request-url" type="text" name="requestUrl" - placeholder='Request URL' + placeholder="Request URL" className="block textbox" autoComplete="off" autoCorrect="off" @@ -87,6 +111,7 @@ const PresetsSettings = ({ collection }) => {
+
+
+ + + ); +}; + +export default Beta; diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js index 2319d4c78..2814ed855 100644 --- a/packages/bruno-app/src/components/Preferences/index.js +++ b/packages/bruno-app/src/components/Preferences/index.js @@ -7,6 +7,7 @@ import General from './General'; import Proxy from './ProxySettings'; import Display from './Display'; import Keybindings from './Keybindings'; +import Beta from './Beta'; import StyledWrapper from './StyledWrapper'; @@ -37,6 +38,10 @@ const Preferences = ({ onClose }) => { return ; } + case 'beta': { + return ; + } + case 'support': { return ; } @@ -46,7 +51,7 @@ const Preferences = ({ onClose }) => { return ( -
+
setTab('general')}> General @@ -63,6 +68,9 @@ const Preferences = ({ onClose }) => {
setTab('support')}> Support
+
setTab('beta')}> + Beta +
{getTabPanel(tab)}
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/StyledWrapper.js new file mode 100644 index 000000000..4b1ebcdb1 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/StyledWrapper.js @@ -0,0 +1,59 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + flex: 1; + /* height: 100%; */ + position: relative; + + .grpc-message-header { + .font-medium { + color: ${(props) => props.theme.text}; + } + + button { + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + } + + #grpc-messages-container { + /* height: 100%; */ + position: relative; + } + + .add-message-btn-container { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding-top: 8px; + background: ${(props) => props.theme.bg || '#fff'}; + z-index: 15; + border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'}; + + .add-message-btn { + width: 100%; + } + } + + .CodeMirror { + border-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + } +`; + +export default Wrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js new file mode 100644 index 000000000..ce7cdebd9 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js @@ -0,0 +1,354 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { get } from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTheme } from 'providers/Theme'; +import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { sendGrpcMessage, generateGrpcSampleMessage } from 'utils/network/index'; +import useLocalStorage from 'hooks/useLocalStorage'; + +import CodeEditor from 'components/CodeEditor/index'; +import StyledWrapper from './StyledWrapper'; +import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons'; +import ToolHint from 'components/ToolHint/index'; +import { toastError } from 'utils/common/error'; +import { format, applyEdits } from 'jsonc-parser'; +import toast from 'react-hot-toast' +import { getAbsoluteFilePath } from 'utils/common/path'; + +const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => { + const dispatch = useDispatch(); + const { displayedTheme, theme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid)); + + // Access gRPC method metadata from local storage + const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {}); + const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {}); + + const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming'; + + const { name, content } = message; + + const onEdit = (value) => { + const currentMessages = [...(body.grpc || [])]; + + currentMessages[index] = { + name: name ? name : `message ${index + 1}`, + content: value + }; + + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const onSend = async () => { + try { + await sendGrpcMessage(item, collection.uid, content); + } catch (error) { + console.error('Error sending message:', error); + } + } + const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const onRegenerateMessage = async () => { + try { + const methodPath = item.draft?.request?.method || item.request?.method; + + if (!methodPath) { + toastError(new Error('Method path not found in request')); + return; + } + + // Get the URL and protoPath to determine which cache to use + const url = item.draft?.request?.url || item.request?.url; + const protoPath = item.draft?.request?.protoPath || item.request?.protoPath; + + // Find the method metadata from the appropriate cache + let methodMetadata = null; + if (protoPath) { + // Use protofile cache if protoPath is available + const absolutePath = getAbsoluteFilePath(protoPath, collection.pathname); + const cachedMethods = protofileCache[absolutePath]; + if (cachedMethods) { + methodMetadata = cachedMethods.find(method => method.path === methodPath); + } + } else if (url) { + // Use reflection cache if no protoPath (reflection mode) + const cachedMethods = reflectionCache[url]; + if (cachedMethods) { + methodMetadata = cachedMethods.find(method => method.path === methodPath); + } + } + + const result = await generateGrpcSampleMessage( + methodPath, + content, + { + arraySize: 2, + methodMetadata // Pass the method metadata to the function + } + ); + + if (result.success) { + const currentMessages = [...(body.grpc || [])]; + + currentMessages[index] = { + name: name ? name : `message ${index + 1}`, + content: result.message + }; + + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + + toast.success('Sample message generated successfully!'); + } else { + toastError(new Error(result.error || 'Failed to generate sample message')); + } + } catch (error) { + console.error('Error generating sample message:', error); + toastError(error); + } + }; + + const onDeleteMessage = () => { + const currentMessages = [...(body.grpc || [])]; + + currentMessages.splice(index, 1); + + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const onPrettify = () => { + try { + const edits = format(content, undefined, { tabSize: 2, insertSpaces: true }); + const prettyBodyJson = applyEdits(content, edits); + + const currentMessages = [...(body.grpc || [])]; + currentMessages[index] = { + name: name ? name : `message ${index + 1}`, + content: prettyBodyJson + }; + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + } catch (e) { + toastError(new Error('Unable to prettify. Invalid JSON format.')); + } + }; + + const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? "" : "h-80"}` : "h-full" + + return ( +
+
+
+ {isCollapsed ? + : + + } + {`Message ${canClientStream ? index + 1 : ''}`} +
+
e.stopPropagation()}> + + + + + + + + + {canClientStream && ( + + + + )} + + {index > 0 && ( + + + + )} +
+
+ + {!isCollapsed && ( +
+ +
+ )} +
+ ) +} + +const GrpcBody = ({ item, collection, handleRun }) => { + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; + const dispatch = useDispatch(); + const [collapsedMessages, setCollapsedMessages] = useState([]); + const messagesContainerRef = useRef(null); + const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + + const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType'); + const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming'; + + // Auto-scroll to the latest message when messages are added + useEffect(() => { + if (messagesContainerRef.current && body?.grpc?.length > 0) { + const container = messagesContainerRef.current; + container.scrollTop = container.scrollHeight; + } + }, [body?.grpc?.length]); + + const toggleMessageCollapse = (index) => { + setCollapsedMessages(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index); + } else { + return [...prev, index]; + } + }); + }; + + const addNewMessage = () => { + const currentMessages = Array.isArray(body.grpc) + ? [...body.grpc] + : []; + + currentMessages.push({ + name: `message ${currentMessages.length + 1}`, + content: '{}' + }); + + dispatch( + updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + + if (!body?.grpc || !Array.isArray(body.grpc)) { + return ( + +
+

No gRPC messages available

+ + + +
+
+ ); + } + + return ( + +
+ {body.grpc + .filter((_, index) => canClientSendMultipleMessages || index === 0) + .map((message, index) => ( + toggleMessageCollapse(index)} + handleRun={handleRun} + canClientSendMultipleMessages={canClientSendMultipleMessages} + /> + ))} +
+ + {canClientSendMultipleMessages && ( +
+ + + +
+ )} +
+ ); +}; + +export default GrpcBody; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/StyledWrapper.js new file mode 100644 index 000000000..64c29be51 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/StyledWrapper.js @@ -0,0 +1,119 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + height: 2.3rem; + + .method-selector-container { + background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; + } + + .input-container { + background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + + input { + background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + outline: none; + box-shadow: none; + + &:focus { + outline: none !important; + box-shadow: none !important; + } + } + } + + .caret { + color: rgb(140, 140, 140); + fill: rgb(140 140 140); + position: relative; + top: 1px; + } + + .infotip { + position: relative; + display: inline-block; + cursor: pointer; + } + + .infotip:hover .infotip-text { + visibility: visible; + opacity: 1; + } + + .infotip-text { + visibility: hidden; + width: auto; + background-color: ${(props) => props.theme.requestTabs.active.bg}; + color: ${(props) => props.theme.text}; + text-align: center; + border-radius: 4px; + padding: 4px 8px; + position: absolute; + z-index: 1; + bottom: 34px; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s; + white-space: nowrap; + } + + .infotip-text::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + margin-left: -4px; + border-width: 4px; + border-style: solid; + border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent; + } + + .shortcut { + font-size: 0.625rem; + } + + @keyframes pulse { + 0% { + opacity: 0.4; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.4; + } + } + + .connection-status-strip { + animation: pulse 1.5s ease-in-out infinite; + background-color: ${(props) => props.theme.colors.text.green}; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + } + + /* Method dropdown styling */ + .method-dropdown { + margin-right: 8px; + position: relative; + z-index: 10; + } + + .dropdown-item { + padding: 8px 12px; + cursor: pointer; + + &:hover { + background-color: ${(props) => props.theme.dropdown.hoverBg}; + } + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js new file mode 100644 index 000000000..150736ae3 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js @@ -0,0 +1,1028 @@ +import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react'; +import get from 'lodash/get'; +import { useDispatch, useSelector } from 'react-redux'; +import { requestUrlChanged, updateRequestMethod, updateRequestProtoPath } from 'providers/ReduxStore/slices/collections'; +import { saveRequest, browseFiles, loadGrpcMethodsFromReflection, openCollectionSettings, generateGrpcurlCommand } from 'providers/ReduxStore/slices/collections/actions'; +import { useTheme } from 'providers/Theme'; +import SingleLineEditor from 'components/SingleLineEditor/index'; +import { isMacOS } from 'utils/common/platform'; +import { getRelativePath, getBasename, getAbsoluteFilePath } from 'utils/common/path'; +import useLocalStorage from 'hooks/useLocalStorage/index'; +import StyledWrapper from './StyledWrapper'; +import ToggleSwitch from 'components/ToggleSwitch/index'; +import { + IconX, + IconCheck, + IconRefresh, + IconDeviceFloppy, + IconArrowRight, + IconCode, + IconFile, + IconChevronDown, + IconSettings, + IconAlertCircle, + IconCopy +} from '@tabler/icons'; +import toast from 'react-hot-toast'; +import { + loadGrpcMethodsFromProtoFile, + cancelGrpcConnection, + endGrpcConnection +} from 'utils/network/index'; +import Dropdown from 'components/Dropdown/index'; +import { + IconGrpcUnary, + IconGrpcClientStreaming, + IconGrpcServerStreaming, + IconGrpcBidiStreaming +} from 'components/Icons/Grpc'; +import Modal from 'components/Modal/index'; +import CodeEditor from 'components/CodeEditor'; +import { debounce } from 'lodash'; +import { getPropertyFromDraftOrRequest } from 'utils/collections'; +import { existsSync } from 'utils/filesystem'; + +// Constants for gRPC method types +const STREAMING_METHOD_TYPES = ['client-streaming', 'server-streaming', 'bidi-streaming']; +const CLIENT_STREAMING_METHOD_TYPES = ['client-streaming', 'bidi-streaming']; + +const GrpcurlModal = ({ isOpen, onClose, command }) => { + const { displayedTheme } = useTheme(); + const [copied, setCopied] = useState(false); + const preferences = useSelector((state) => state.app.preferences); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + toast.success('Command copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + toast.error('Failed to copy command'); + } + }; + + return ( + + Generate gRPCurl Command + BETA +
+ } + size="lg" + hideFooter={true} + > +
+
+
+
+ +
+ +
+
+
+ + ); +}; + +const GrpcQueryUrl = ({ item, collection, handleRun }) => { + const { theme, storedTheme } = useTheme(); + const dispatch = useDispatch(); + const method = getPropertyFromDraftOrRequest(item, 'request.method'); + const type = getPropertyFromDraftOrRequest(item, 'request.type'); + const url = getPropertyFromDraftOrRequest(item, 'request.url', ''); + const protoPath = getPropertyFromDraftOrRequest(item, 'request.protoPath'); + const isMac = isMacOS(); + const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S'; + const editorRef = useRef(null); + const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid)); + const [protoFilePath, setProtoFilePath] = useState(protoPath); + const [grpcMethods, setGrpcMethods] = useState([]); + const [isLoadingMethods, setIsLoadingMethods] = useState(false); + const [selectedGrpcMethod, setSelectedGrpcMethod] = useState({ + path: method, + type: type + }); + const methodDropdownRef = useRef(); + const protoDropdownRef = useRef(); + const haveFetchedMethodsRef = useRef(false); + const [showGrpcurlModal, setShowGrpcurlModal] = useState(false); + const [grpcurlCommand, setGrpcurlCommand] = useState(''); + const [isReflectionMode, setIsReflectionMode] = useState(false); + const collectionProtoFiles = get(collection, 'brunoConfig.grpc.protoFiles', []); + const [reflectionCache, setReflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {}); + const [protofileCache, setProtofileCache] = useLocalStorage('bruno.grpc.protofileCache', {}); + const fileExistsCache = useRef(new Map()); + const [showProtoDropdown, setShowProtoDropdown] = useState(false); + + const fileExists = useCallback(async (filePath) => { + if (!filePath) return false; + + if (fileExistsCache.current.has(filePath)) { + return fileExistsCache.current.get(filePath); + } + + try { + const absolutePath = getAbsoluteFilePath(filePath, collection.pathname); + const exists = await existsSync(absolutePath); + fileExistsCache.current.set(filePath, exists); + return exists; + } catch (error) { + console.error('Error checking if file exists:', error); + return false; + } + }, [collection.pathname]); + + const [collectionProtoFilesExistence, setCollectionProtoFilesExistence] = useState([]); + + useEffect(() => { + const fetchCollectionProtoFilesExistence = async () => { + if (!collectionProtoFiles) return; + const existence = await Promise.all(collectionProtoFiles.map(async (protoFile) => { + const absolutePath = getAbsoluteFilePath(protoFile.path, collection.pathname); + const exists = await fileExists(absolutePath) + return { + path: protoFile.path, + absolutePath, + exists + } + })); + setCollectionProtoFilesExistence(existence); + }; + fetchCollectionProtoFilesExistence(); + }, [fileExists]); + + const invalidProtoFiles = useMemo(() => { + return collectionProtoFilesExistence.filter(file => !file.exists); + }, [collectionProtoFilesExistence]); + + const currentProtoFileExists = useMemo(() => { + return fileExists(protoFilePath); + }, [protoFilePath, fileExists]); + + const onMethodDropdownCreate = (ref) => (methodDropdownRef.current = ref); + const onProtoDropdownCreate = (ref) => (protoDropdownRef.current = ref); + + + const isStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type); + const isClientStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && CLIENT_STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type); + + const onSave = (finalValue) => { + dispatch(saveRequest(item.uid, collection.uid)); + }; + + const onUrlChange = (value) => { + if (!editorRef.current?.editor) return; + const editor = editorRef.current.editor; + const cursor = editor.getCursor(); + + const finalUrl = value?.trim() || value; + + dispatch( + requestUrlChanged({ + itemUid: item.uid, + collectionUid: collection.uid, + url: finalUrl + }) + ); + + // Restore cursor position only if URL was trimmed + if (finalUrl !== value) { + setTimeout(() => { + if (editor) { + editor.setCursor(cursor); + } + }, 0); + } + + if(!protoFilePath && value) { + setIsReflectionMode(true); + handleReflection(finalUrl); + } + }; + + const onMethodSelect = ({ path, type }) => { + if (isConnectionActive) { + cancelGrpcConnection(item.uid) + .then(() => { + toast.success('gRPC connection cancelled'); + }) + .catch((err) => { + console.error('Failed to cancel gRPC connection:', err); + }); + } + + dispatch( + updateRequestMethod({ + method: path, + methodType: type, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const handleReflection = async (url, isManualRefresh = false) => { + if (!url) return; + + const cachedMethods = reflectionCache[url]; + if (!isManualRefresh && cachedMethods && !isLoadingMethods) { + setGrpcMethods(cachedMethods); + setProtoFilePath(''); + setIsReflectionMode(true); + const isDuplicateSave = !item.request.protoPath; + if (!isDuplicateSave) { + dispatch(updateRequestProtoPath({ + protoPath: '', + itemUid: item.uid, + collectionUid: collection.uid + })); + } + + if (cachedMethods && cachedMethods.length > 0) { + const haveSelectedMethod = + selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path); + if (!haveSelectedMethod) { + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + } else if (selectedGrpcMethod) { + // Update the method type for the currently selected method to ensure it matches + const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path); + if (currentMethod) { + const methodType = currentMethod.type; + setSelectedGrpcMethod({ + path: selectedGrpcMethod.path, + type: methodType + }); + } + } + return; + } + } + + setIsLoadingMethods(true); + try { + const { methods, error } = await dispatch(loadGrpcMethodsFromReflection(item, collection.uid, url)); + + if (error) { + console.error('Error loading gRPC methods:', error); + toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); + return; + } + + // Cache the methods for this URL + setReflectionCache(prevCache => ({ + ...prevCache, + [url]: methods + })); + + setGrpcMethods(methods); + setProtoFilePath(''); + setIsReflectionMode(true); + const isDuplicateSave = !item.request.protoPath; + if (!isDuplicateSave) { + dispatch(updateRequestProtoPath({ + protoPath: '', + itemUid: item.uid, + collectionUid: collection.uid + })); + } + + if (methods && methods.length > 0) { + const haveSelectedMethod = + selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); + if (!haveSelectedMethod) { + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + } else if (selectedGrpcMethod) { + // Update the method type for the currently selected method to ensure it matches + const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); + if (currentMethod) { + const methodType = currentMethod.type; + setSelectedGrpcMethod({ + path: selectedGrpcMethod.path, + type: methodType + }); + } + } + toast.success(`Loaded ${methods.length} gRPC methods from reflection`); + } + } catch (error) { + console.error('Error loading gRPC methods:', error); + toast.error('Failed to load gRPC methods from reflection'); + } finally { + setIsLoadingMethods(false); + } + }; + + const handleGrpcurl = async (url) => { + if (!url) { + toast.error('Please enter a valid gRPC server URL'); + return; + } + + if (!selectedGrpcMethod?.path) { + toast.error('Please select a gRPC method'); + return; + } + + try { + const result = await dispatch(generateGrpcurlCommand(item, collection.uid)); + + if (result.success) { + setGrpcurlCommand(result.command); + setShowGrpcurlModal(true); + } else { + toast.error(result.error || 'Failed to generate grpcurl command'); + } + } catch (error) { + console.error('Error generating grpcurl command:', error); + toast.error('Failed to generate grpcurl command'); + } + }; + + // Add a new function to group methods by service + const groupMethodsByService = (methods) => { + if (!methods || !methods.length) return {}; + + const groupedMethods = {}; + + methods.forEach(method => { + // The format is "/service.ServiceName/MethodName" + const pathWithoutLeadingSlash = method.path.startsWith('/') ? method.path.slice(1) : method.path; + const parts = pathWithoutLeadingSlash.split('/'); + + // The service is the part before the last slash + const serviceName = parts[0] || 'Default'; + // The method name is the part after the last slash + const methodName = parts[1] || method.path; + + // Store the extracted method name for easier display + const enhancedMethod = { + ...method, + serviceName, + methodName + }; + + if (!groupedMethods[serviceName]) { + groupedMethods[serviceName] = []; + } + + groupedMethods[serviceName].push(enhancedMethod); + }); + + return groupedMethods; + }; + + const MethodsDropdownIcon = forwardRef((props, ref) => { + return ( +
+ {selectedGrpcMethod &&
{getIconForMethodType(selectedGrpcMethod.type)}
} + + {selectedGrpcMethod ? ( + + {selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path} + + ) : ( + Select Method + )} + + +
+ ); + }); + + const ProtoFileDropdownIcon = forwardRef((props, ref) => { + return ( +
setShowProtoDropdown(prev => !prev)}> + {isReflectionMode ? (<> + ) : ( + + )} + + {isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(protoFilePath) : 'Select Proto File')} + + +
+ ); + }); + + const handleGrpcMethodSelect = (method) => { + const methodType = method.type + setSelectedGrpcMethod({ + path: method.path, + type: methodType + }); + onMethodSelect({ path: method.path, type: methodType }); + }; + + const getIconForMethodType = (type) => { + switch (type) { + case 'unary': + return ; + case 'client-streaming': + return ; + case 'server-streaming': + return ; + case 'bidi-streaming': + return ; + default: + return ; + } + }; + + const handleCancelConnection = (e) => { + e.stopPropagation(); + + cancelGrpcConnection(item.uid) + .then(() => { + toast.success('gRPC connection cancelled'); + }) + .catch((err) => { + console.error('Failed to cancel gRPC connection:', err); + toast.error('Failed to cancel gRPC connection'); + }); + }; + + const handleEndConnection = (e) => { + e.stopPropagation(); + + endGrpcConnection(item.uid) + .then(() => { + toast.success('gRPC stream ended'); + }) + .catch((err) => { + console.error('Failed to end gRPC stream:', err); + toast.error('Failed to end gRPC stream'); + }); + }; + + const handleSelectCollectionProtoFile = (protoFile) => { + try { + if (!protoFile) { + toast.error('No proto file selected'); + return; + } + + // Get the absolute path from the relative path + const absolutePath = protoFile.absolutePath; + + if (!protoFile.exists) { + toast.error(`Proto file not found: ${protoFile.path}`); + return; + } + + setProtoFilePath(protoFile.path); + setIsReflectionMode(false); + + dispatch(updateRequestProtoPath({ + protoPath: protoFile.path, + itemUid: item.uid, + collectionUid: collection.uid + })); + + loadMethodsFromProtoFile(absolutePath); + } catch (error) { + console.error('Error selecting collection proto file:', error); + toast.error('Failed to select collection proto file'); + } + }; + + const handleResetProtoFile = () => { + setProtoFilePath(''); + setIsReflectionMode(true); + const isDuplicateSave = !item.request.protoPath; + if (!isDuplicateSave) { + dispatch(updateRequestProtoPath({ + protoPath: '', + itemUid: item.uid, + collectionUid: collection.uid + })); + } + setGrpcMethods([]); + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + toast.success('Proto file reset'); + }; + + const loadMethodsFromProtoFile = async (filePath, isManualRefresh = false) => { + if (!filePath) { + toast.error('No proto file selected'); + return; + }; + const absolutePath = getAbsoluteFilePath(filePath, collection.pathname); + + // Check if we have cached methods for this proto file + const cachedMethods = protofileCache[absolutePath]; + if (cachedMethods && !isLoadingMethods && !isManualRefresh) { + setGrpcMethods(cachedMethods); + + if (cachedMethods && cachedMethods.length > 0) { + // Check if currently selected method is still valid + const haveSelectedMethod = + selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path); + if (!haveSelectedMethod) { + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + } else { + // Update the method type for the currently selected method to ensure it matches + const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path); + if (currentMethod) { + const methodType = currentMethod.type; + setSelectedGrpcMethod({ + path: selectedGrpcMethod.path, + type: methodType + }); + } + } + } + return; + } + + setIsLoadingMethods(true); + try { + const { methods, error } = await loadGrpcMethodsFromProtoFile(absolutePath); + + if (error) { + console.error('Error loading gRPC methods:', error); + toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); + return; + } + + // Cache the methods for this proto file + setProtofileCache(prevCache => ({ + ...prevCache, + [absolutePath]: methods + })); + + setGrpcMethods(methods); + + if (methods && methods.length > 0) { + toast.success(`Loaded ${methods.length} gRPC methods from proto file`); + + // Check if currently selected method is still valid + const haveSelectedMethod = + selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); + if (!haveSelectedMethod) { + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + } else { + // Update the method type for the currently selected method to ensure it matches + const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); + if (currentMethod) { + const methodType = currentMethod.type; + setSelectedGrpcMethod({ + path: selectedGrpcMethod.path, + type: methodType + }); + } + } + } else { + toast.warning('No gRPC methods found in proto file'); + } + } catch (err) { + console.error('Error loading gRPC methods:', err); + toast.error('Failed to load gRPC methods from proto file'); + } finally { + setIsLoadingMethods(false); + } + }; + + const handleSelectProtoFile = (e) => { + e.stopPropagation(); + const filters = [{ name: 'Proto Files', extensions: ['proto'] }]; + + dispatch(browseFiles(filters, [''])) + .then((filePaths) => { + if (filePaths && filePaths.length > 0) { + const filePath = filePaths[0]; + const relativePath = getRelativePath(filePath, collection.pathname); + setProtoFilePath(relativePath); + setIsReflectionMode(false); + + dispatch(updateRequestProtoPath({ + protoPath: relativePath, + itemUid: item.uid, + collectionUid: collection.uid + })); + + // Load methods from the newly selected proto file + const absolutePath = getAbsoluteFilePath(relativePath, collection.pathname); + loadMethodsFromProtoFile(absolutePath); + } + }) + .catch((err) => { + console.error('Error selecting proto file:', err); + toast.error('Failed to select proto file'); + }); + }; + + const handleOpenCollectionGrpc = () => { + dispatch(openCollectionSettings(collection.uid, 'grpc')); + }; + + const debouncedOnUrlChange = debounce(onUrlChange, 1000); + + useEffect(() => { + fileExistsCache.current.clear(); + }, [collection.pathname]); + + useEffect(() => { + if(haveFetchedMethodsRef.current) { + return; + } + haveFetchedMethodsRef.current = true; + + if(protoFilePath) { + setIsReflectionMode(false); + loadMethodsFromProtoFile(protoFilePath); + return; + } + if (!url) return; + setIsReflectionMode(true); + handleReflection(url); + + }, []); + + return ( + +
+
+ gRPC +
+
+
+ onSave(finalValue)} + theme={storedTheme} + onChange={(newValue) => debouncedOnUrlChange(newValue)} + onRun={handleRun} + collection={collection} + highlightPathParams={true} + item={item} + /> + + {grpcMethods && grpcMethods.length > 0 && ( +
+ } placement="bottom-end" style={{ maxWidth: "unset" }}> +
+ {Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => ( +
+
+ {serviceName || 'Default Service'} +
+
+ {methods.map((method, methodIndex) => ( +
handleGrpcMethodSelect(method)} + > +
+
{getIconForMethodType(method.type)}
+
+
{method.methodName}
+
{method.type}
+
+
+
+ ))} +
+
+ ))} +
+
+
+ )} +
+
+ } + placement="bottom-end" + visible={showProtoDropdown} + onClickOutside={() => setShowProtoDropdown(false)} + > +
+
+

{isReflectionMode ? "Using Reflection" : "Select Proto File"}

+
+ + {/* Mode Toggle */} +
+
+ Mode +
+ + Proto File + + { + e.stopPropagation(); + e.preventDefault(); + setIsReflectionMode(!isReflectionMode); + if (!isReflectionMode) { + // Switching to reflection mode + setProtoFilePath(''); + dispatch(updateRequestProtoPath({ + protoPath: '', + itemUid: item.uid, + collectionUid: collection.uid + })); + if (url) { + handleReflection(url); + } + } else { + // Switching to proto file mode + setGrpcMethods([]); + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + } + }} + size="2xs" + /> + + Reflection + +
+
+
+ + {!isReflectionMode && ( + <> + {collectionProtoFiles && collectionProtoFiles.length > 0 && ( +
+
+
From Collection Settings
+ +
+ + {invalidProtoFiles.length > 0 && ( +
+

+ + Some proto files could not be found. +

+
+ )} + +
+ {collectionProtoFilesExistence.map((protoFile, index) => { + const isSelected = protoFilePath === protoFile.absolutePath; + const isInvalid = !protoFile.exists; + + return ( +
{ + if (!isInvalid) { + setShowProtoDropdown(false); + handleSelectCollectionProtoFile(protoFile); + } + }} + > +
+
+ +
+
+ {getBasename(protoFile.absolutePath)} + {isInvalid && ( + + + + )} +
+
{protoFile.path}
+
+
+
+
+ ); + })} +
+
+ )} + + {collectionProtoFiles && collectionProtoFiles.length > 0 && ( +
+ )} + + {protoFilePath && !collectionProtoFilesExistence.some(pf => + pf.absolutePath === protoFilePath + ) && ( +
+
Current Proto File
+ {!currentProtoFileExists && ( +
+

+ + Selected proto file not found. Please select a valid proto file from collection settings or browse for a new one. +

+
+ )} +
+
+
+ +
+
+ {getBasename(protoFilePath)} + {!currentProtoFileExists && ( + + + + )} +
+
{protoFilePath}
+
+
+
+ +
+
+
+ +
+ )} + +
+ +
+ + )} + + {isReflectionMode && ( +
+
+ Using server reflection to discover gRPC methods. +
+
+ )} +
+
+
+ +
{ + e.stopPropagation(); + if (isReflectionMode) { + handleReflection(url, true); + } else if (protoFilePath) { + loadMethodsFromProtoFile(protoFilePath, true); + } else { + toast.error('No proto file selected'); + } + }} + > + + + {isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'} + +
+ +
{ + e.stopPropagation(); + handleGrpcurl(url); + }} + > + + Generate grpcurl command +
+ +
{ + e.stopPropagation(); + if (!item.draft) return; + onSave(); + }} + > + + + Save ({saveShortcut}) + +
+ + {isConnectionActive && isStreamingMethod && ( +
+
+ + Cancel +
+ + {isClientStreamingMethod &&
+ +
} +
+ )} + + {(!isConnectionActive || !isStreamingMethod) && ( +
{ + e.stopPropagation(); + handleRun(e); + }} + > + +
+ )} +
+
+ {isConnectionActive && isStreamingMethod && ( +
+ )} + + {showGrpcurlModal && ( + setShowGrpcurlModal(false)} + command={grpcurlCommand} + /> + )} +
+ ); +}; + +export default GrpcQueryUrl; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js new file mode 100644 index 000000000..3b95b708f --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js @@ -0,0 +1,85 @@ +import React, { useRef, forwardRef } from 'react'; +import get from 'lodash/get'; +import { IconCaretDown } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import { useDispatch } from 'react-redux'; +import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections'; +import { humanizeRequestAuthMode } from 'utils/collections'; +import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper'; + +const GrpcAuthMode = ({ item, collection }) => { + const dispatch = useDispatch(); + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); + + const authModes = [ + { + name: 'Basic Auth', + mode: 'basic' + }, + { + name: 'Bearer Token', + mode: 'bearer' + }, + { + name: 'API Key', + mode: 'apikey' + }, + { + name: 'OAuth2', + mode: 'oauth2' + }, + { + name: 'Inherit', + mode: 'inherit' + }, + { + name: 'No Auth', + mode: 'none' + } + ]; + + const Icon = forwardRef((props, ref) => { + return ( +
+ {humanizeRequestAuthMode(authMode)} +
+ ); + }); + + const onModeChange = (value) => { + dispatch( + updateRequestAuthMode({ + itemUid: item.uid, + collectionUid: collection.uid, + mode: value + }) + ); + }; + + const onClickHandler = (mode) => { + dropdownTippyRef?.current?.hide(); + onModeChange(mode); + }; + + return ( + +
+ } placement="bottom-end"> + {authModes.map((authMode) => ( +
onClickHandler(authMode.mode)} + > + {authMode.name} +
+ ))} +
+
+
+ ); +}; + +export default GrpcAuthMode; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/StyledWrapper.js new file mode 100644 index 000000000..f76b0d9a4 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/StyledWrapper.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + .inherit-mode-text { + color: ${(props) => props.theme.colors.text.yellow}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js new file mode 100644 index 000000000..b30be89e5 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js @@ -0,0 +1,125 @@ +import React, { useEffect } from 'react'; +import get from 'lodash/get'; +import { useDispatch } from 'react-redux'; +import GrpcAuthMode from './GrpcAuthMode'; +import BearerAuth from '../../Auth/BearerAuth'; +import BasicAuth from '../../Auth/BasicAuth'; +import ApiKeyAuth from '../../Auth/ApiKeyAuth'; +import OAuth2 from '../../Auth/OAuth2/index'; +import StyledWrapper from './StyledWrapper'; +import { humanizeRequestAuthMode } from 'utils/collections'; +import { getTreePathFromCollectionToItem } from 'utils/collections/index'; +import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; + +// List of auth modes supported by gRPC +const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit']; + +const GrpcAuth = ({ item, collection }) => { + const dispatch = useDispatch(); + const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); + const requestTreePath = getTreePathFromCollectionToItem(collection, item); + + const request = item.draft + ? get(item, 'draft.request', {}) + : get(item, 'request', {}); + + const save = () => { + return saveRequest(item.uid, collection.uid); + }; + + // Reset to 'none' if current auth mode is not supported by gRPC + useEffect(() => { + if (authMode && !supportedGrpcAuthModes.includes(authMode)) { + dispatch( + updateRequestAuthMode({ + itemUid: item.uid, + collectionUid: collection.uid, + mode: 'none' + }) + ); + } + }, [authMode, collection.uid, dispatch, item.uid]); + + const getEffectiveAuthSource = () => { + if (authMode !== 'inherit') return null; + + const collectionAuth = get(collection, 'root.request.auth'); + let effectiveSource = { + type: 'collection', + name: 'Collection', + auth: collectionAuth + }; + + // Check folders in reverse to find the closest auth configuration + for (let i of [...requestTreePath].reverse()) { + if (i.type === 'folder') { + const folderAuth = get(i, 'root.request.auth'); + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + effectiveSource = { + type: 'folder', + name: i.name, + auth: folderAuth + }; + break; + } + } + } + + return effectiveSource; + }; + + const getAuthView = () => { + switch (authMode) { + case 'basic': { + return ; + } + case 'bearer': { + return ; + } + case 'apikey': { + return ; + } + case 'oauth2': { + return ; + } + case 'inherit': { + const source = getEffectiveAuthSource(); + + // Only show inherited auth if it's one of the supported types + if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) { + return ( + <> +
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ + ); + } else { + return ( + <> +
+
Inherited auth not supported by gRPC. Using no auth instead.
+
+ + ); + } + } + default: { + return null; + } + } + }; + + return ( + +
+ +
+ {getAuthView()} +
+ ); +}; + +export default GrpcAuth; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js new file mode 100644 index 000000000..e6a766672 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js @@ -0,0 +1,34 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + div.tabs { + div.tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + + .content-indicator { + color: ${(props) => props.theme.text} + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js new file mode 100644 index 000000000..ef961b79d --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js @@ -0,0 +1,124 @@ +import React from 'react'; +import classnames from 'classnames'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; +import RequestHeaders from 'components/RequestPane/RequestHeaders'; +import GrpcBody from 'components/RequestPane/GrpcBody'; +import GrpcAuth from './GrpcAuth/index'; +import StatusDot from 'components/StatusDot/index'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; +import StyledWrapper from './StyledWrapper'; +import { find, get } from 'lodash'; +import Documentation from 'components/Documentation/index'; +import { useEffect } from 'react'; +import { getPropertyFromDraftOrRequest } from 'utils/collections/index'; + +const GrpcRequestPane = ({ item, collection, handleRun }) => { + const dispatch = useDispatch(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + + const selectTab = (tab) => { + dispatch( + updateRequestPaneTab({ + uid: item.uid, + requestPaneTab: tab + }) + ); + }; + + const getTabPanel = (tab) => { + switch (tab) { + case 'body': { + return ; + } + case 'headers': { + return ; + } + case 'auth': { + return ; + } + case 'docs': { + return ; + } + default: { + return
404 | Not found
; + } + } + }; + + if (!activeTabUid) { + return
Something went wrong
; + } + + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) { + return
An error occurred!
; + } + + const getTabClassname = (tabName) => { + return classnames(`tab select-none ${tabName}`, { + active: tabName === focusedTab.requestPaneTab + }); + }; + + const isMultipleContentTab = ['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab); + const body = getPropertyFromDraftOrRequest(item, 'request.body'); + const headers = getPropertyFromDraftOrRequest(item, 'request.headers'); + const docs = getPropertyFromDraftOrRequest(item, 'request.docs'); + const auth = getPropertyFromDraftOrRequest(item, 'request.auth'); + + const activeHeadersLength = headers.filter((header) => header.enabled).length; + const grpcMessagesCount = body?.grpc?.length || 0; + + // Determine if this is a client streaming request + const request = item.draft ? item.draft.request : item.request; + const isClientStreaming = request.methodType === 'client-streaming' || request.methodType === 'bidi-streaming'; + + useEffect(() => { + // Only set the tab to 'body' if no tab is currently set + if (!focusedTab?.requestPaneTab) { + selectTab('body'); + } + }, []); + + return ( + +
+
selectTab('body')}> + Message + {grpcMessagesCount > 0 && ( + isClientStreaming ? ( + {grpcMessagesCount} + ) : ( + + ) + )} +
+
selectTab('headers')}> + Metadata + {activeHeadersLength > 0 && {activeHeadersLength}} +
+
selectTab('auth')}> + Auth + {auth.mode !== 'none' && } +
+
selectTab('docs')}> + Docs + {docs && docs.length > 0 && } +
+
+
+ + {getTabPanel(focusedTab.requestPaneTab)} + +
+
+ ); +}; + +export default GrpcRequestPane; diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index 2035ba00c..3fe47b041 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -20,6 +20,7 @@ const QueryUrl = ({ item, collection, handleRun }) => { const isMac = isMacOS(); const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S'; const editorRef = useRef(null); + const isGrpc = item.type === 'grpc-request'; const [methodSelectorWidth, setMethodSelectorWidth] = useState(90); const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); @@ -80,7 +81,14 @@ const QueryUrl = ({ item, collection, handleRun }) => { return (
- + {isGrpc ? ( +
+ gRPC +
+ + ) : ( + + )}
{
} placement="bottom-end"> -
Form
-
{ - dropdownTippyRef.current.hide(); - onModeChange('multipartForm'); - }} - > - Multipart Form -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('formUrlEncoded'); - }} - > - Form URL Encoded -
-
Raw
-
{ - dropdownTippyRef.current.hide(); - onModeChange('json'); - }} - > - JSON -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('xml'); - }} - > - XML -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('text'); - }} - > - TEXT -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('sparql'); - }} - > - SPARQL -
-
Other
-
{ - dropdownTippyRef.current.hide(); - onModeChange('file'); - }} - > - File / Binary -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('none'); - }} - > - No Body -
+
Form
+
{ + dropdownTippyRef.current.hide(); + onModeChange('multipartForm'); + }} + > + Multipart Form +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('formUrlEncoded'); + }} + > + Form URL Encoded +
+
Raw
+
{ + dropdownTippyRef.current.hide(); + onModeChange('json'); + }} + > + JSON +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('xml'); + }} + > + XML +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('text'); + }} + > + TEXT +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('sparql'); + }} + > + SPARQL +
+
Other
+
{ + dropdownTippyRef.current.hide(); + onModeChange('file'); + }} + > + File / Binary +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('none'); + }} + > + No Body +
{(bodyMode === 'json' || bodyMode === 'xml') && ( diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js index d562684e5..fdc674ae6 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js @@ -79,4 +79,4 @@ const RequestBody = ({ item, collection }) => { return No Body; }; -export default RequestBody; +export default RequestBody; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index ddcc62af2..930a056f9 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -16,7 +16,7 @@ import BulkEditor from '../../BulkEditor'; const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); -const RequestHeaders = ({ item, collection }) => { +const RequestHeaders = ({ item, collection, addHeaderText }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); @@ -181,7 +181,7 @@ const RequestHeaders = ({ item, collection }) => {
- + {item.type === 'grpc-request' ? ( + + ) : ( + + )}
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 816b00e25..fe574fdaa 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -22,6 +22,7 @@ import { flattenItems } from 'utils/collections/index'; const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const theme = storedTheme === 'dark' ? darkTheme : lightTheme; const [showConfirmClose, setShowConfirmClose] = useState(false); const dropdownTippyRef = useRef(); @@ -65,10 +66,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi }; const getMethodColor = (method = '') => { - const theme = storedTheme === 'dark' ? darkTheme : lightTheme; return theme.request.methods[method.toLocaleLowerCase()]; }; + const folder = folderUid ? findItemInCollection(collection, folderUid) : null; if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) { return ( @@ -107,6 +108,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi ); } + const isGrpc = item.type === 'grpc-request'; const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method'); return ( @@ -159,8 +161,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi } }} > - - {method} + + {isGrpc ? 'gRPC' : method} {item.name} diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/StyledWrapper.js new file mode 100644 index 000000000..f302b86dd --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/StyledWrapper.js @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + border-left: 4px solid ${(props) => props.theme.colors.text.danger}; + border-top: 1px solid transparent; + border-right: 1px solid transparent; + border-bottom: 1px solid transparent; + border-radius: 0.375rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + max-height: 200px; + min-height: 70px; + overflow-y: auto; + background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')}; + + .close-button { + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + + svg { + color: ${(props) => props.theme.text}; + } + } + + .error-title { + font-weight: 600; + margin-bottom: 0.375rem; + color: ${(props) => props.theme.colors.text.danger}; + } + + .error-message { + font-family: monospace; + font-size: 0.6875rem; + line-height: 1.25rem; + white-space: pre-wrap; + word-break: break-all; + color: ${(props) => props.theme.text}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/index.js new file mode 100644 index 000000000..d2376401d --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/index.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { IconX } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const GrpcError = ({ error, onClose }) => { + if (!error) return null; + + return ( + +
+
+
gRPC Server Error
+
{typeof error === 'string' ? error : JSON.stringify(error, null, 2)}
+
+
+ +
+
+
+ ); +}; + +export default GrpcError; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/StyledWrapper.js new file mode 100644 index 000000000..81b4c33b1 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/StyledWrapper.js @@ -0,0 +1,96 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + height: 100%; + overflow: hidden; + background: ${(props) => props.theme.bg}; + border-radius: 4px; + + .CodeMirror { + height: 100%; + font-family: ${(props) => (props.font === 'default' ? 'monospace' : props.font)}; + font-size: ${(props) => (props.fontSize ? props.fontSize : '13px')}; + } + + .accordion-header { + background-color: ${(props) => props.theme.requestTabPanel.card.bg}; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + + &.open { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + } + + .error-header { + background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(185, 28, 28, 0.1)' : '#fee2e2')}; + } + + .error-text { + color: ${(props) => props.theme.colors.text.danger}; + } + + div.tabs { + div.tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + } + } + + .stream-status { + display: inline-flex; + align-items: center; + + &.complete { + color: ${(props) => props.theme.colors.text.green}; + } + + &.cancelled { + color: ${(props) => props.theme.colors.text.danger}; + } + + &.streaming { + color: ${(props) => props.theme.colors.text.blue}; + } + } + + .message-counter { + display: inline-flex; + align-items: center; + margin-left: 10px; + } + + .response-list { + max-height: 500px; + overflow-y: auto; + } + + .response-message { + margin-bottom: 8px; + padding: 8px; + border-radius: 4px; + background-color: var(--color-panel-background); + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/index.js new file mode 100644 index 000000000..c6ceeff61 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/index.js @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from 'react'; +import Accordion from 'components/Accordion'; +import CodeEditor from 'components/CodeEditor'; +import { get } from 'lodash'; +import { useSelector } from 'react-redux'; +import { useTheme } from 'providers/Theme/index'; +import StyledWrapper from './StyledWrapper'; +import { formatISO9075 } from 'date-fns'; +import GrpcError from '../GrpcError'; + +const GrpcQueryResult = ({ item, collection }) => { + const { displayedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + const [showErrorMessage, setShowErrorMessage] = useState(true); + + const response = item.response || {}; + const responsesList = response?.responses || []; + // Reverse the responses list to show the most recent at the top + const reversedResponsesList = [...responsesList].reverse(); + const hasError = response.isError; + const hasResponses = responsesList.length > 0; + const errorMessage = response.error; + + // Reset error visibility when a new response is received + useEffect(() => { + if (hasError) { + setShowErrorMessage(true); + } + }, [response, hasError]); + + // Format a timestamp to a human-readable format + const formatTimestamp = (timestamp) => { + if (!timestamp) return 'Unknown time'; + + try { + const date = new Date(timestamp); + return formatISO9075(date); + } catch (e) { + return 'Invalid time'; + } + }; + + // Format JSON for display + const formatJSON = (data) => { + try { + if (typeof data === 'string') { + return JSON.stringify(JSON.parse(data), null, 2); + } + return JSON.stringify(data, null, 2); + } catch (e) { + return typeof data === 'string' ? data : JSON.stringify(data); + } + }; + + if (!hasResponses && !hasError) { + return ( + +
No messages received
+
+ ); + } + + return ( + + {hasError && showErrorMessage && setShowErrorMessage(false)} />} + {hasResponses && ( +
+ {responsesList.length === 1 ? ( + // Single message - render directly without accordion +
+ +
+ ) : ( + // Multiple messages - use accordion + + {reversedResponsesList.map((response, index) => { + // Calculate the original response number (for display purposes) + const originalIndex = responsesList.length - index - 1; + + return ( + + +
+
+ Response {originalIndex + 1} {index === 0 ? '(Latest)' : ''} +
+
+
+ +
+ +
+
+
+ ); + })} +
+ )} +
+ )} + {hasError && !hasResponses && !showErrorMessage && ( +
+ No messages received. A server error occurred but has been dismissed. +
+ )} +
+ ); +}; + +export default GrpcQueryResult; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/StyledWrapper.js new file mode 100644 index 000000000..d42e77f7f --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/StyledWrapper.js @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + table { + width: 100%; + border-collapse: collapse; + + thead { + color: #777777; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + td { + padding: 6px 10px; + + &.value { + word-break: break-all; + } + } + + tbody { + tr:nth-child(odd) { + background-color: ${(props) => props.theme.table.striped}; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/index.js new file mode 100644 index 000000000..a7390558d --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/index.js @@ -0,0 +1,38 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const GrpcResponseHeaders = ({ metadata }) => { + // Ensure headers is an array + const metadataArray = Array.isArray(metadata) ? metadata : []; + + return ( + + + + + + + + + + {metadataArray && metadataArray.length ? ( + metadataArray.map((metadata, index) => ( + + + + + )) + ) : ( + + + + )} + +
NameValue
{metadata.name}{metadata.value}
+ No metadata received +
+
+ ); +}; + +export default GrpcResponseHeaders; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/StyledWrapper.js new file mode 100644 index 000000000..bed367559 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/StyledWrapper.js @@ -0,0 +1,22 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + font-size: 0.75rem; + font-weight: 600; + display: flex; + align-items: center; + + &.text-ok { + color: ${(props) => props.theme.requestTabPanel.responseOk}; + } + + &.text-pending { + color: ${(props) => props.theme.requestTabPanel.responsePending}; + } + + &.text-error { + color: ${(props) => props.theme.requestTabPanel.responseError}; + } +`; + +export default Wrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/get-grpc-status-code-phrase.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/get-grpc-status-code-phrase.js new file mode 100644 index 000000000..a8c96e645 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/get-grpc-status-code-phrase.js @@ -0,0 +1,22 @@ +// https://grpc.github.io/grpc/core/md_doc_statuscodes.html +const grpcStatusCodePhraseMap = { + 0: 'OK', + 1: 'Cancelled', + 2: 'Unknown', + 3: 'Invalid Argument', + 4: 'Deadline Exceeded', + 5: 'Not Found', + 6: 'Already Exists', + 7: 'Permission Denied', + 8: 'Resource Exhausted', + 9: 'Failed Precondition', + 10: 'Aborted', + 11: 'Out of Range', + 12: 'Unimplemented', + 13: 'Internal', + 14: 'Unavailable', + 15: 'Data Loss', + 16: 'Unauthenticated' +}; + +export default grpcStatusCodePhraseMap; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/index.js new file mode 100644 index 000000000..af4f10db6 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import classnames from 'classnames'; +import grpcStatusCodePhraseMap from './get-grpc-status-code-phrase'; +import StyledWrapper from './StyledWrapper'; + +const GrpcStatusCode = ({ status, text }) => { + // gRPC status codes: 0 is success, anything else is an error + const getTabClassname = (status) => { + const isPending = text === 'PENDING' || text === 'STREAMING'; + return classnames('ml-2', { + 'text-ok': parseInt(status) === 0, + 'text-pending': isPending, + 'text-error': parseInt(status) > 0 && !isPending + }); + }; + + const statusText = text || grpcStatusCodePhraseMap[status] + + return ( + + {Number.isInteger(status) ?
{status}
: null} + {statusText &&
{statusText}
} +
+ ); +}; + +export default GrpcStatusCode; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/StyledWrapper.js new file mode 100644 index 000000000..d42e77f7f --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/StyledWrapper.js @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + table { + width: 100%; + border-collapse: collapse; + + thead { + color: #777777; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + td { + padding: 6px 10px; + + &.value { + word-break: break-all; + } + } + + tbody { + tr:nth-child(odd) { + background-color: ${(props) => props.theme.table.striped}; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/index.js new file mode 100644 index 000000000..7df1f5b45 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/index.js @@ -0,0 +1,37 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const ResponseTrailers = ({ trailers }) => { + const trailersArray = Array.isArray(trailers) ? trailers : []; + + return ( + + + + + + + + + + {trailersArray && trailersArray.length ? ( + trailersArray.map((trailer, index) => ( + + + + + )) + ) : ( + + + + )} + +
NameValue
{trailer.name}{trailer.value}
+ No trailers received +
+
+ ); +}; + +export default ResponseTrailers; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js new file mode 100644 index 000000000..e4e358af4 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js @@ -0,0 +1,58 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + height: 100%; + overflow: hidden; + background: ${(props) => props.theme.bg}; + border-radius: 4px; + + div.tabs { + div.tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + } + } + + .stream-status { + display: inline-flex; + align-items: center; + + &.complete { + color: ${(props) => props.theme.colors.text.green}; + } + + &.cancelled { + color: ${(props) => props.theme.colors.text.danger}; + } + + &.streaming { + color: ${(props) => props.theme.colors.text.blue}; + } + } + + .message-counter { + display: inline-flex; + align-items: center; + margin-left: 10px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js new file mode 100644 index 000000000..81ab5bc1e --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js @@ -0,0 +1,160 @@ +import React, { useState, useEffect } from 'react'; +import find from 'lodash/find'; +import classnames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; +import Overlay from '../Overlay'; +import Placeholder from '../Placeholder'; +import GrpcResponseHeaders from './GrpcResponseHeaders'; +import GrpcStatusCode from './GrpcStatusCode'; +import ResponseTime from '../ResponseTime/index'; +import Timeline from '../Timeline'; +import ClearTimeline from '../ClearTimeline'; +import ResponseSave from '../ResponseSave'; +import ResponseClear from '../ResponseClear'; +import StyledWrapper from './StyledWrapper'; +import ResponseTrailers from './ResponseTrailers'; +import GrpcQueryResult from './GrpcQueryResult'; +import ResponseLayoutToggle from '../ResponseLayoutToggle'; +import Tab from 'components/Tab'; + +const GrpcResponsePane = ({ item, collection }) => { + const dispatch = useDispatch(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const isLoading = ['queued', 'sending'].includes(item.requestState); + + const requestTimeline = [...(collection?.timeline || [])].filter((obj) => { + if (obj.itemUid === item.uid) return true; + }); + + const selectTab = (tab) => { + dispatch( + updateResponsePaneTab({ + uid: item.uid, + responsePaneTab: tab + }) + ); + }; + + const response = item.response || {}; + + const getTabPanel = (tab) => { + switch (tab) { + case 'response': { + return ; + } + case 'headers': { + return ; + } + case 'trailers': { + return ; + } + case 'timeline': { + return ; + } + default: { + return
404 | Not found
; + } + } + }; + + if (isLoading && !item.response) { + return ( + + + + ); + } + + if (!item.response && !requestTimeline?.length) { + return ( + + + + ); + } + + if (!activeTabUid) { + return
Something went wrong
; + } + + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + if (!focusedTab || !focusedTab.uid || !focusedTab.responsePaneTab) { + return
An error occurred!
; + } + + const tabConfig = [ + { + name: 'response', + label: 'Response', + count: Array.isArray(response.responses) ? response.responses.length : 0 + }, + { + name: 'headers', + label: 'Metadata', + count: Array.isArray(response.metadata) ? response.metadata.length : 0 + }, + { + name: 'trailers', + label: 'Trailers', + count: Array.isArray(response.trailers) ? response.trailers.length : 0 + }, + { + name: 'timeline', + label: 'Timeline' + } + ]; + + return ( + +
+ {tabConfig.map((tab) => ( + + ))} + {!isLoading ? ( +
+ {focusedTab?.responsePaneTab === 'timeline' ? ( + <> + + + + ) : item?.response ? ( + <> + + + + + + ) : null} +
+ ) : null} +
+
+ {isLoading ? : null} + {!item?.response ? ( + focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( + + ) : null + ) : ( + <>{getTabPanel(focusedTab.responsePaneTab)} + )} +
+
+ ); +}; + +export default GrpcResponsePane; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js index b1cff2157..fcdeaaca3 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js @@ -20,7 +20,7 @@ const ResponseSize = ({ size }) => { } return ( - + {sizeToDisplay} ); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js index ed05e944c..52b8b84a3 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js @@ -1,9 +1,9 @@ import React from 'react'; import StyledWrapper from './StyledWrapper'; +import isNumber from 'lodash/isNumber'; const ResponseTime = ({ duration }) => { let durationToDisplay = ''; - if (duration > 1000) { // duration greater than a second let seconds = Math.floor(duration / 1000); @@ -13,6 +13,10 @@ const ResponseTime = ({ duration }) => { durationToDisplay = duration + 'ms'; } + if (!isNumber(duration)) { + return null; + } + return {durationToDisplay}; }; export default ResponseTime; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js new file mode 100644 index 000000000..5e049118e --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js @@ -0,0 +1,274 @@ +import { useState } from "react"; +import { RelativeTime } from "../TimelineItem/Common/Time/index"; +import Status from "../TimelineItem/Common/Status/index"; +import { + IconChevronDown, + IconChevronRight, + IconServer, + IconDatabase, + IconAlertCircle, + IconCircleCheck, + IconCircleX, + IconX, + IconSend +} from '@tabler/icons'; + +// Icons for different event types +const EventTypeIcons = { + metadata: , + response: , + request: , + message: , + status: , + error: , + end: , + cancel: +}; + +// Event type display names +const EventTypeNames = { + metadata: "Metadata", + response: "Response Message", + request: "Request", + message: "Message", + status: "Status", + error: "Error", + end: "Stream Ended", + cancel: "Cancelled" +}; + +// Colors for different event types +const EventTypeColors = { + metadata: "border-blue-500/20", + response: "border-green-500/20", + request: "border-orange-500/20", + message: "border-orange-500/20", + status: "border-purple-500/20", + error: "border-red-500/20", + end: "border-gray-500/20", + cancel: "border-amber-500/20" +}; + +const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, item, collection, width }) => { + const [isCollapsed, setIsCollapsed] = useState(true); + const toggleCollapse = () => setIsCollapsed(prev => !prev); + + // Use requestSent if available, otherwise fall back to request + const effectiveRequest = item.requestSent || request || item.request || {}; + + // Extract relevant data from request and response + const { method, url = '' } = effectiveRequest; + const { statusCode, statusText, duration } = response || {}; + + // Get event-specific icon and color + const eventIcon = EventTypeIcons[eventType] || ; + const eventColor = EventTypeColors[eventType] || "border-gray-500/50"; + const eventName = EventTypeNames[eventType] || "Event"; + + + // Render appropriate content based on event type + const renderEventContent = () => { + + const isClientStreaming = effectiveRequest.methodType === 'client-streaming' || effectiveRequest.methodType === 'bidi-streaming'; + + switch(eventType) { + case 'request': + return ( +
+ + {effectiveRequest.headers && Object.keys(effectiveRequest.headers).length > 0 && ( +
+
Metadata
+
+ {Object.entries(effectiveRequest.headers).map(([key, value], idx) => ( +
+
{key}:
+
{value}
+
+ ))} +
+
+ )} + + {/* gRPC Messages section */} + {!isClientStreaming && effectiveRequest.body?.mode === 'grpc' && effectiveRequest.body?.grpc?.length > 0 && ( +
+
+ Message +
+
+ {effectiveRequest.body.grpc.filter((_, index) => index === 0).map((message, idx) => ( +
+
+                        {typeof message.content === 'string' 
+                          ? message.content 
+                          : JSON.stringify(message.content, null, 2)}
+                      
+
+ ))} +
+
+ )} +
+ ); + + case 'message': + return ( +
+
Message
+
+                {typeof eventData === 'string' 
+                  ? eventData 
+                  : JSON.stringify(eventData, null, 2)}
+              
+
+ ); + + case 'metadata': + return ( +
+
Metadata Headers
+ {response.metadata && response.metadata.length > 0 ? ( +
+ {response.metadata.map((header, idx) => ( +
+
{header.name}:
+
{header.value}
+
+ ))} +
+ ) : ( +
No metadata headers
+ )} +
+ ); + + case 'response': + // For message responses, show the response data + return ( +
+
+ Response Message #{(response.responses.length || 0)} +
+ {response.responses && response.responses.length > 0 ? ( +
+                {JSON.stringify(response.responses[response.responses.length - 1], null, 2)}
+              
+ ) : ( +
Empty message
+ )} +
+ ); + + case 'status': + // For status events, show status and trailers + return ( +
+
+ +
+ + {response.statusDescription && ( +
{response.statusDescription}
+ )} + + {response.trailers && response.trailers.length > 0 && ( + <> +
Trailers
+
+ {response.trailers.map((trailer, idx) => ( +
+
{trailer.name}:
+
{trailer.value || ''}
+
+ ))} +
+ + )} +
+ ); + + case 'error': + // For error events, show error details + return ( +
+
Error
+
{response.error || "Unknown error"}
+ +
+ +
+ + {response.trailers && response.trailers.length > 0 && ( + <> +
Error Metadata
+
+ {response.trailers.map((trailer, idx) => ( +
+
{trailer.name}:
+
{trailer.value}
+
+ ))} +
+ + )} +
+ ); + + case 'end': + // For end events, show summary + return ( +
+
Stream Ended
+
+ Total messages: {response.responses.length || 0} +
+
+ ); + + case 'cancel': + // For cancel events, show cancellation info + return ( +
+
Stream Cancelled
+
{response.statusDescription || "The gRPC stream was cancelled"}
+
+ ); + + default: + return null; + } + }; + + return ( +
+
+ {isCollapsed ? : } + {eventIcon} + {eventName} + {eventType === 'request' && effectiveRequest.methodType && ( + + {effectiveRequest.methodType} + + )} + {eventType === 'status' && ( +
+ +
+ )} +
[{new Date(timestamp).toISOString()}]
+ + + +
+ + {/* Always show the URL */} +
{url}
+ + {/* Expanded content - only show for non-status items */} + {!isCollapsed && renderEventContent()} +
+ ); +}; + +export default GrpcTimelineItem; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js index 4b7cb28a7..263d45245 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js @@ -1,6 +1,15 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + position: relative; + overflow-y: auto; + height: 100%; + flex: 1; + + .timeline-container { + flex: 1; + } + .timeline-event { padding: 8px 0 0 0; cursor: pointer; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js index ff33e41ec..c5fc91295 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js @@ -16,7 +16,7 @@ const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2 return (
-
+
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js index 98fe1479a..79bf5725b 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; +import React from 'react'; import StyledWrapper from './StyledWrapper'; import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index'; import { get } from 'lodash'; import TimelineItem from './TimelineItem/index'; +import GrpcTimelineItem from './GrpcTimelineItem/index'; const getEffectiveAuthSource = (collection, item) => { const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); @@ -44,9 +45,10 @@ const getEffectiveAuthSource = (collection, item) => { const Timeline = ({ collection, item }) => { // Get the effective auth source if auth mode is inherit const authSource = getEffectiveAuthSource(collection, item); - + const isGrpcRequest = item.type === 'grpc-request'; + // Filter timeline entries based on new rules - const combinedTimeline = ([...(collection.timeline || [])]).filter(obj => { + const combinedTimeline = ([...(collection?.timeline || [])]).filter(obj => { // Always show entries for this item if (obj.itemUid === item.uid) return true; @@ -57,43 +59,68 @@ const Timeline = ({ collection, item }) => { } return false; - }).sort((a, b) => b.timestamp - a.timestamp); + }).sort((a, b) => b.timestamp - a.timestamp) return ( - {combinedTimeline.map((event, index) => { - if (event.type === 'request') { - const { data, timestamp } = event; - const { request, response } = data; - return ( -
- -
- ); - } else if (event.type === 'oauth2') { - const { data, timestamp } = event; - const { debugInfo } = data; - return ( -
-
-
- OAuth2.0 Calls + {/* Timeline container with scrollbar */} +
+ {combinedTimeline.map((event, index) => { + // Handle regular requests + if (event.type === 'request') { + + const { data, timestamp, eventType } = event; + const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data; + + if (isGrpcRequest) { + return ( +
+
+ ); + } + + // Regular HTTP request + return ( +
+
+ ); + } + // Handle OAuth2 events + else if (event.type === 'oauth2') { + const { data, timestamp } = event; + const { debugInfo } = data; + return ( +
+
+
+ OAuth2.0 Calls +
+
{debugInfo && debugInfo.length > 0 ? ( debugInfo.map((data, idx) => ( -
+
{
No debug information available.
)}
-
- ); - } - - return null; - })} +
+ ); + } + + return null; + })} +
); }; diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js index 1a448d221..609515c8c 100644 --- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import get from 'lodash/get'; import classnames from 'classnames'; -import { safeStringifyJSON } from 'utils/common'; import QueryResult from 'components/ResponsePane/QueryResult'; import ResponseHeaders from 'components/ResponsePane/ResponseHeaders'; import StatusCode from 'components/ResponsePane/StatusCode'; diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 6b18ae837..7fb5fd523 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import Modal from 'components/Modal'; -import { IconDownload, IconLoader2 } from '@tabler/icons'; +import { IconDownload, IconLoader2, IconAlertTriangle } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; import Bruno from 'components/Bruno'; import exportBrunoCollection from 'utils/collections/export'; @@ -11,9 +11,22 @@ import { useSelector } from 'react-redux'; import { findCollectionByUid, areItemsLoading } from 'utils/collections/index'; const ShareCollection = ({ onClose, collectionUid }) => { - const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid)); + const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const isCollectionLoading = areItemsLoading(collection); - + + const hasGrpcRequests = useMemo(() => { + const checkItem = (item) => { + if (item.type === 'grpc-request') { + return true; + } + if (item.items) { + return item.items.some(checkItem); + } + return false; + }; + return collection?.items?.some(checkItem) || false; + }, [collection]); + const handleExportBrunoCollection = () => { const collectionCopy = cloneDeep(collection); exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); @@ -36,38 +49,39 @@ const ShareCollection = ({ onClose, collectionUid }) => { hideCancel > -
-
-
- {isCollectionLoading ? ( - - ) : ( - - )} -
-
-
Bruno Collection
-
- {isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'} -
-
+
+
+
+ {isCollectionLoading ? : }
- -
+
+
Bruno Collection
+
{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}
+
+
+ +
+ {hasGrpcRequests && ( +
+ + Note: gRPC requests in this collection will not be exported +
+ )} +
{isCollectionLoading ? ( @@ -83,6 +97,7 @@ const ShareCollection = ({ onClose, collectionUid }) => {
+
); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js index bdb62e843..04d338d5e 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js @@ -34,6 +34,9 @@ const Wrapper = styled.div` .method-head { color: ${(props) => props.theme.request.methods.head}; } + .method-grpc { + color: ${(props) => props.theme.request.grpc}; + } `; export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js index e41309871..73cfc50ed 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js @@ -3,10 +3,12 @@ import classnames from 'classnames'; import StyledWrapper from './StyledWrapper'; const RequestMethod = ({ item }) => { - if (!['http-request', 'graphql-request'].includes(item.type)) { + if (!['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) { return null; } + const isGrpc = item.type === 'grpc-request'; + const getClassname = (method = '') => { method = method.toLocaleLowerCase(); return classnames('mr-1', { @@ -16,7 +18,8 @@ const RequestMethod = ({ item }) => { 'method-delete': method === 'delete', 'method-patch': method === 'patch', 'method-head': method === 'head', - 'method-options': method == 'options' + 'method-options': method === 'options', + 'method-grpc': isGrpc, }); }; @@ -24,7 +27,7 @@ const RequestMethod = ({ item }) => {
- {item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method} + {isGrpc ? 'grpc' : item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js index f27cfc52e..8a668409c 100644 --- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js @@ -7,7 +7,7 @@ import { uuid } from 'utils/common'; import Modal from 'components/Modal'; import { useDispatch, useSelector } from 'react-redux'; import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections'; -import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { newHttpRequest, newGrpcRequest } from 'providers/ReduxStore/slices/collections/actions'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector'; import { getDefaultRequestPaneTab } from 'utils/collections'; @@ -21,14 +21,16 @@ import Help from 'components/Help'; import StyledWrapper from './StyledWrapper'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { useTheme } from 'styled-components'; +import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { const dispatch = useDispatch(); const inputRef = useRef(); const storedTheme = useTheme(); + const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC); - const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); + const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const { brunoConfig: { presets: collectionPresets = {} } } = collection; @@ -44,7 +46,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { const Icon = forwardRef((props, ref) => { return (
- {curlRequestTypeDetected === 'http-request' ? "HTTP" : "GraphQL"} + {curlRequestTypeDetected === 'http-request' ? 'HTTP' : 'GraphQL'}
); @@ -89,6 +91,14 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { return 'graphql-request'; } + if (collectionPresets.requestType === 'grpc') { + // If gRPC is disabled in beta features, fall back to http-request + if (!isGrpcEnabled) { + return 'http-request'; + } + return 'grpc-request'; + } + return 'http-request'; }; @@ -113,11 +123,15 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { .min(1, 'must be at least 1 character') .max(255, 'must be 255 characters or less') .required('filename is required') - .test('is-valid-filename', function(value) { + .test('is-valid-filename', function (value) { const isValid = validateName(value); return isValid ? true : this.createError({ message: validateNameError(value) }); }) - .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value)), + .test( + 'not-reserved', + `The file names "collection" and "folder" are reserved in bruno`, + (value) => !['collection', 'folder'].includes(value) + ), curlCommand: Yup.string().when('requestType', { is: (requestType) => requestType === 'from-curl', then: Yup.string() @@ -131,7 +145,27 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { }) }), onSubmit: (values) => { - if (isEphemeral) { + const isGrpcRequest = values.requestType === 'grpc-request'; + + if (isGrpcRequest) { + dispatch( + newGrpcRequest({ + requestName: values.requestName, + filename: values.filename, + requestType: values.requestType, + requestUrl: values.requestUrl, + collectionUid: collection.uid, + itemUid: item ? item.uid : null + }) + ) + .then(() => { + toast.success('New request created!'); + onClose(); + }) + .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); + + // will need to handle import from grpcurl command when we support it, now it is just for creating new requests + } else if (isEphemeral) { const uid = uuid(); dispatch( newEphemeralHttpRequest({ @@ -176,7 +210,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { ) .then(() => { toast.success('New request created!'); - onClose() + onClose(); }) .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); } else { @@ -193,7 +227,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { ) .then(() => { toast.success('New request created!'); - onClose() + onClose(); }) .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); } @@ -248,13 +282,10 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { const AdvancedOptions = forwardRef((props, ref) => { return (
- - +
); }); @@ -308,6 +339,26 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { GraphQL + {isGrpcEnabled && ( + <> + { + formik.setFieldValue('requestMethod', 'POST'); + formik.handleChange(event); + }} + value="grpc-request" + checked={formik.values.requestType === 'grpc-request'} + /> + + + )} + { autoCorrect="off" autoCapitalize="off" spellCheck="false" - onChange={e => { + onChange={(e) => { formik.setFieldValue('requestName', e.target.value); !isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value)); }} @@ -352,34 +403,33 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
{isEditing ? ( - toggleEditing(false)} + toggleEditing(false)} /> ) : ( toggleEditing(true)} + className="cursor-pointer opacity-50 hover:opacity-80" + size={16} + strokeWidth={1.5} + onClick={() => toggleEditing(true)} /> )}
{isEditing ? ( -
+
{ onChange={formik.handleChange} value={formik.values.filename || ''} /> - .bru + .bru
) : ( -
- +
+
)} {formik.touched.filename && formik.errors.filename ? ( @@ -414,12 +462,14 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { URL
-
- formik.setFieldValue('requestMethod', val)} - /> -
+ {formik.values.requestType !== 'grpc-request' ? ( +
+ formik.setFieldValue('requestMethod', val)} + /> +
+ ) : null}
{ onChange={(value) => { formik.handleChange({ target: { - name: "requestUrl", + name: 'requestUrl', value: value } }); @@ -484,9 +534,9 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
)}
-
+
} placement="bottom-start"> -
{ @@ -498,17 +548,14 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
-
- +
+ - diff --git a/packages/bruno-app/src/components/Tab/index.js b/packages/bruno-app/src/components/Tab/index.js new file mode 100644 index 000000000..dd74c9261 --- /dev/null +++ b/packages/bruno-app/src/components/Tab/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import classnames from 'classnames'; + +const Tab = ({ name, label, isActive, onClick, count = 0, className = '', ...props }) => { + const tabClassName = classnames("tab select-none", { + active: isActive + }, className); + + return ( +
onClick(name)} + {...props} + > + {label} + {count > 0 && {count}} +
+ ); +}; + +export default Tab; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ToggleSwitch/index.js b/packages/bruno-app/src/components/ToggleSwitch/index.js index cf386a347..bb3679038 100644 --- a/packages/bruno-app/src/components/ToggleSwitch/index.js +++ b/packages/bruno-app/src/components/ToggleSwitch/index.js @@ -2,8 +2,8 @@ import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper'; const ToggleSwitch = ({ isOn, handleToggle, size = 'm', ...props }) => { return ( - - + + {}} />