From 3cdd4c8491b2b0d0a5d7d4d4b86baa329fa02bf9 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Wed, 19 Jul 2023 11:58:37 +0200 Subject: [PATCH] SSE/EventStream support (#6147) * new request * add button * s * fix formatting issue * detect event stream * hide dropdown * wip * add basic test * pass 1 can make connection * implement event stream open pass 2 * wire up ready state * wiring up close and error pass 3 * can open and close connections * clean header * listen to timeline * extract options to function * fix bug in ws * add debug stuff to timeline * don't rely on redux to set active response * fix close type * rename sse to event stream * get request flag from request model * copy websocket response pane * hide response data * flatten ws and curl responses * fix test * fix catch * reset file * use realtime event watcher * rename some files * fix types * fix e2e test * fix lint * consistent empty states * pin to bottom * remove todo * add SSE logo * add sse to readme --- .vscode/settings.json | 2 +- README.md | 2 +- .../fixtures/smoke-test-collection.yaml | 34 +- packages/insomnia-smoke-test/server/index.ts | 11 +- .../tests/smoke/app.test.ts | 21 +- packages/insomnia/src/common/constants.ts | 1 + packages/insomnia/src/main.development.ts | 2 + packages/insomnia/src/main/ipc/main.ts | 2 + packages/insomnia/src/main/network/curl.ts | 358 ++++++++++++++++++ .../src/main/network/libcurl-promise.ts | 295 ++++++++------- .../insomnia/src/main/network/websocket.ts | 15 +- packages/insomnia/src/models/request.ts | 4 + packages/insomnia/src/models/response.ts | 1 + packages/insomnia/src/preload.ts | 14 + .../ui/components/codemirror/code-editor.tsx | 15 + .../components/editors/body/body-editor.tsx | 16 +- .../ui/components/panes/empty-state-pane.tsx | 37 +- .../src/ui/components/request-url-bar.tsx | 67 +++- .../sidebar/sidebar-create-dropdown.tsx | 9 +- .../sidebar/sidebar-request-row.tsx | 18 +- .../viewers/response-timeline-viewer.tsx | 4 +- .../ui/components/viewers/response-viewer.tsx | 3 +- .../ui/components/websockets/action-bar.tsx | 2 +- .../components/websockets/event-log-view.tsx | 9 +- .../ui/components/websockets/event-view.tsx | 49 +-- ...se-pane.tsx => realtime-response-pane.tsx} | 49 +-- .../websockets/websocket-request-pane.tsx | 27 +- .../insomnia/src/ui/hooks/create-request.ts | 21 +- .../use-ready-state.ts} | 25 ++ .../use-realtime-connection-events.ts} | 9 +- packages/insomnia/src/ui/routes/debug.tsx | 28 +- 31 files changed, 853 insertions(+), 297 deletions(-) create mode 100644 packages/insomnia/src/main/network/curl.ts rename packages/insomnia/src/ui/components/websockets/{websocket-response-pane.tsx => realtime-response-pane.tsx} (85%) rename packages/insomnia/src/ui/{context/websocket-client/use-ws-ready-state.ts => hooks/use-ready-state.ts} (55%) rename packages/insomnia/src/ui/{context/websocket-client/use-ws-connection-events.ts => hooks/use-realtime-connection-events.ts} (53%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 598ba7d6f..25c1980dd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,6 @@ }, "files.insertFinalNewline": true, "editor.formatOnSave": true, - "editor.formatOnSaveMode": "modificationsIfAvailable", + "editor.formatOnSaveMode": "file", "editor.defaultFormatter": "vscode.typescript-language-features", } diff --git a/README.md b/README.md index 603e2383a..693667124 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Slack Channel](https://chat.insomnia.rest/badge.svg)](https://chat.insomnia.rest/) [![license](https://img.shields.io/github/license/Kong/insomnia.svg)](LICENSE) -Insomnia is an open-source, cross-platform API client for GraphQL, REST, WebSockets and gRPC. +Insomnia is an open-source, cross-platform API client for GraphQL, REST, WebSockets, Server-sent events and gRPC. ![Insomnia API Client](https://raw.githubusercontent.com/Kong/insomnia/develop/screenshots/main.png) diff --git a/packages/insomnia-smoke-test/fixtures/smoke-test-collection.yaml b/packages/insomnia-smoke-test/fixtures/smoke-test-collection.yaml index 8ae90b708..8ac3ce91d 100644 --- a/packages/insomnia-smoke-test/fixtures/smoke-test-collection.yaml +++ b/packages/insomnia-smoke-test/fixtures/smoke-test-collection.yaml @@ -84,6 +84,31 @@ resources: settingRebuildPath: true settingFollowRedirects: global _type: request + - _id: req_e6fab3568ed54f9d83c92e2c8006e150 + parentId: wrk_5b5ab67830944ffcbec47528366ef403 + modified: 1636141084436 + created: 1636141078601 + url: http://127.0.0.1:4010/events + name: connects to event stream and shows ping response + description: "" + method: GET + body: {} + parameters: [] + headers: + - id: pair_4c3fe3092f1245eab6d960c633d8be8c + name: Accept + value: text/event-stream + description: "" + authentication: {} + metaSortKey: -1636141078601 + isPrivate: false + settingStoreCookies: true + settingSendCookies: true + settingDisableRenderRequestBody: false + settingEncodeUrl: true + settingRebuildPath: true + settingFollowRedirects: global + _type: request - _id: req_06a660bffe724e3fac14d7d09b4a5c9b parentId: wrk_5b5ab67830944ffcbec47528366ef403 modified: 1636141067089 @@ -197,11 +222,4 @@ resources: settingRebuildPath: true settingFollowRedirects: global _type: request - - _id: spc_255904e6a8774d24b29c9c3718feb07f - parentId: wrk_5b5ab67830944ffcbec47528366ef403 - modified: 1636140994428 - created: 1636140994428 - fileName: Smoke tests - contents: "" - contentType: yaml - _type: api_spec + diff --git a/packages/insomnia-smoke-test/server/index.ts b/packages/insomnia-smoke-test/server/index.ts index 6b85c784a..5784c4e5e 100644 --- a/packages/insomnia-smoke-test/server/index.ts +++ b/packages/insomnia-smoke-test/server/index.ts @@ -1,8 +1,9 @@ +import crypto from 'node:crypto'; + import express from 'express'; import { graphqlHTTP } from 'express-graphql'; import { readFileSync } from 'fs'; import { createServer } from 'https'; -import crypto from 'node:crypto'; import { join } from 'path'; import { basicAuthRouter } from './basic-auth'; @@ -42,7 +43,7 @@ gitlabApi(app); app.get('/delay/seconds/:duration', (req, res) => { const delaySec = Number.parseInt(req.params.duration || '2'); - setTimeout(function() { + setTimeout(function () { res.send(`Delayed by ${delaySec} seconds`); }, delaySec * 1000); }); @@ -77,6 +78,12 @@ app.get('/events', (request, response) => { response, }; subscribers.push(subscriber); + setInterval(() => { + // const id = subscriberId; + const data = JSON.stringify({ message: 'Time: ' + new Date().toISOString().slice(11, 19) }); + // response.write('id: ' + id + '\n'); + response.write('data: ' + data + '\n\n'); + }, 1000); request.on('close', () => { console.log(`${subscriberId} Connection closed`); subscribers = subscribers.filter(sub => sub.id !== subscriberId); diff --git a/packages/insomnia-smoke-test/tests/smoke/app.test.ts b/packages/insomnia-smoke-test/tests/smoke/app.test.ts index 8cf3e3398..908b2a7ce 100644 --- a/packages/insomnia-smoke-test/tests/smoke/app.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/app.test.ts @@ -22,40 +22,47 @@ test('can send requests', async ({ app, page }) => { await page.getByText('CollectionSmoke testsjust now').click(); await page.getByRole('button', { name: 'send JSON request' }).click(); - await page.click('text=http://127.0.0.1:4010/pets/1Send >> button'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await expect(statusTag).toContainText('200 OK'); await expect(responseBody).toContainText('"id": "1"'); await page.getByRole('button', { name: 'Preview' }).click(); await page.getByRole('menuitem', { name: 'Raw Data' }).click(); await expect(responseBody).toContainText('{"id":"1"}'); + await page.getByRole('button', { name: 'connects to event stream and shows ping response' }).click(); + await page.getByTestId('request-pane').getByRole('button', { name: 'Connect' }).click(); + await expect(statusTag).toContainText('200 OK'); + await page.getByRole('tab', { name: 'Timeline' }).click(); + await expect(responseBody).toContainText('Connected to 127.0.0.1'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Disconnect' }).click(); + await page.getByRole('button', { name: 'sends dummy.csv request and shows rich response' }).click(); - await page.click('text=http://127.0.0.1:4010/file/dummy.csvSend >> button'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await expect(statusTag).toContainText('200 OK'); await page.getByRole('button', { name: 'Preview' }).click(); await page.getByRole('menuitem', { name: 'Raw Data' }).click(); await expect(responseBody).toContainText('a,b,c'); await page.getByRole('button', { name: 'sends dummy.xml request and shows raw response' }).click(); - await page.click('text=http://127.0.0.1:4010/file/dummy.xmlSend >> button'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await expect(statusTag).toContainText('200 OK'); await expect(responseBody).toContainText('xml version="1.0"'); await expect(responseBody).toContainText(''); await page.getByRole('button', { name: 'sends dummy.pdf request and shows rich response' }).click(); - await page.click('text=http://127.0.0.1:4010/file/dummy.pdfSend >> button'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await expect(statusTag).toContainText('200 OK'); // TODO(filipe): re-add a check for the preview that is less flaky await page.getByRole('tab', { name: 'Timeline' }).click(); await page.locator('pre').filter({ hasText: '< Content-Type: application/pdf' }).click(); await page.getByRole('button', { name: 'sends request with basic authentication' }).click(); - await page.click('text=http://127.0.0.1:4010/auth/basicSend >> button'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await expect(statusTag).toContainText('200 OK'); await expect(responseBody).toContainText('basic auth received'); await page.getByRole('button', { name: 'sends request with cookie and get cookie in response' }).click(); - await page.click('text=http://127.0.0.1:4010/cookiesSend >> button'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await expect(statusTag).toContainText('200 OK'); await page.getByRole('tab', { name: 'Timeline' }).click(); await expect(responseBody).toContainText('Set-Cookie: insomnia-test-cookie=value123'); @@ -76,7 +83,7 @@ test('can cancel requests', async ({ app, page }) => { await page.getByText('CollectionSmoke testsjust now').click(); await page.getByRole('button', { name: 'delayed request' }).click(); - await page.click('text=http://127.0.0.1:4010/delay/seconds/20Send >> button'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await page.getByRole('button', { name: 'Cancel Request' }).click(); await page.click('text=Request was cancelled'); diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index 5d8bcd3a2..00bf68547 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -253,6 +253,7 @@ export const CONTENT_TYPE_JSON = 'application/json'; export const CONTENT_TYPE_PLAINTEXT = 'text/plain'; export const CONTENT_TYPE_XML = 'application/xml'; export const CONTENT_TYPE_YAML = 'text/yaml'; +export const CONTENT_TYPE_EVENT_STREAM = 'text/event-stream'; export const CONTENT_TYPE_EDN = 'application/edn'; export const CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded'; export const CONTENT_TYPE_FORM_DATA = 'multipart/form-data'; diff --git a/packages/insomnia/src/main.development.ts b/packages/insomnia/src/main.development.ts index a2b352ce9..76f34b68b 100644 --- a/packages/insomnia/src/main.development.ts +++ b/packages/insomnia/src/main.development.ts @@ -12,6 +12,7 @@ import { validateInsomniaConfig } from './common/validate-insomnia-config'; import { registerElectronHandlers } from './main/ipc/electron'; import { registergRPCHandlers } from './main/ipc/grpc'; import { registerMainHandlers } from './main/ipc/main'; +import { registerCurlHandlers } from './main/network/curl'; import { registerWebSocketHandlers } from './main/network/websocket'; import { initializeSentry, sentryWatchAnalyticsEnabled } from './main/sentry'; import { checkIfRestartNeeded } from './main/squirrel-startup'; @@ -70,6 +71,7 @@ app.on('ready', async () => { registerMainHandlers(); registergRPCHandlers(); registerWebSocketHandlers(); + registerCurlHandlers(); /** * There's no option that prevents Electron from fetching spellcheck dictionaries from Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from fetching. diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 21bd1e4b5..e2d29db67 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -13,6 +13,7 @@ import { exportAllWorkspaces } from '../export'; import { insomniaFetch } from '../insomniaFetch'; import installPlugin from '../install-plugin'; import { axiosRequest } from '../network/axios-request'; +import { CurlBridgeAPI } from '../network/curl'; import { cancelCurlRequest, curlRequest } from '../network/libcurl-promise'; import { WebSocketBridgeAPI } from '../network/websocket'; import { gRPCBridgeAPI } from './grpc'; @@ -34,6 +35,7 @@ export interface MainBridgeAPI { on: (channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void) => () => void; webSocket: WebSocketBridgeAPI; grpc: gRPCBridgeAPI; + curl: CurlBridgeAPI; trackSegmentEvent: (options: { event: string; properties?: Record }) => void; trackPageView: (options: { name: string }) => void; axiosRequest: typeof axiosRequest; diff --git a/packages/insomnia/src/main/network/curl.ts b/packages/insomnia/src/main/network/curl.ts new file mode 100644 index 000000000..53d9737d1 --- /dev/null +++ b/packages/insomnia/src/main/network/curl.ts @@ -0,0 +1,358 @@ +import { Readable } from 'node:stream'; + +import { Curl, CurlFeature, CurlInfoDebug, HeaderInfo } from '@getinsomnia/node-libcurl'; +import electron, { ipcMain } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import tls from 'tls'; +import { v4 as uuidV4 } from 'uuid'; + +import { describeByteSize, generateId, getSetCookieHeaders } from '../../common/misc'; +import * as models from '../../models'; +import { CookieJar } from '../../models/cookie-jar'; +import { Environment } from '../../models/environment'; +import { RequestAuthentication, RequestHeader } from '../../models/request'; +import { Response } from '../../models/response'; +import { addSetCookiesToToughCookieJar } from '../../network/network'; +import { urlMatchesCertHost } from '../../network/url-matches-cert-host'; +import { invariant } from '../../utils/invariant'; +import { setDefaultProtocol } from '../../utils/url/protocol'; +import { createConfiguredCurlInstance } from './libcurl-promise'; + +export interface CurlConnection extends Curl { + _id: string; + requestId: string; +} + +export interface CurlOpenEvent { + _id: string; + requestId: string; + type: 'open'; + timestamp: number; +} + +export interface CurlMessageEvent { + _id: string; + requestId: string; + type: 'message'; + timestamp: number; + data: string; + direction: 'OUTGOING' | 'INCOMING'; +} + +export interface CurlErrorEvent { + _id: string; + requestId: string; + type: 'error'; + timestamp: number; + message: string; + error: Error; +} + +export interface CurlCloseEvent { + _id: string; + requestId: string; + type: 'close'; + timestamp: number; + statusCode: number; + reason: string; + wasClean: boolean; + code: number; +} + +export type CurlEvent = + | CurlOpenEvent + | CurlMessageEvent + | CurlErrorEvent + | CurlCloseEvent; + +export type CurlEventLog = CurlEvent[]; + +const CurlConnections = new Map(); +const eventLogFileStreams = new Map(); +const timelineFileStreams = new Map(); + +const parseHeadersAndBuildTimeline = (url: string, headersWithStatus: HeaderInfo) => { + const { result, ...headers } = headersWithStatus; + const statusMessage = result?.reason || ''; + const statusCode = result?.code || 0; + const httpVersion = result?.version; + const responseHeaders = Object.entries(headers).map(([name, value]) => ({ name, value: value?.toString() || '' })); + const timeline = [ + { value: `Preparing request to ${url}`, name: 'Text', timestamp: Date.now() }, + ]; + return { timeline, responseHeaders, statusCode, statusMessage, httpVersion }; +}; +interface OpenCurlRequestOptions { + requestId: string; + workspaceId: string; + url: string; + headers: RequestHeader[]; + authentication: RequestAuthentication; + cookieJar: CookieJar; + initialPayload?: string; +} +const openCurlConnection = async ( + event: Electron.IpcMainInvokeEvent, + options: OpenCurlRequestOptions +): Promise => { + const existingConnection = CurlConnections.get(options.requestId); + + if (existingConnection) { + console.warn('Connection still open to ' + existingConnection.getInfo(Curl.info.EFFECTIVE_URL)); + return; + } + const request = await models.request.getById(options.requestId); + const responseId = generateId('res'); + if (!request) { + console.warn('Could not find request for ' + options.requestId); + return; + } + + const responsesDir = path.join(process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData'), 'responses'); + fs.mkdirSync(responsesDir, { recursive: true }); + + const responseBodyPath = path.join(responsesDir, uuidV4() + '.response'); + eventLogFileStreams.set(options.requestId, fs.createWriteStream(responseBodyPath)); + const timelinePath = path.join(responsesDir, responseId + '.timeline'); + timelineFileStreams.set(options.requestId, fs.createWriteStream(timelinePath)); + + const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(options.workspaceId); + const environmentId: string = workspaceMeta.activeEnvironmentId || 'n/a'; + const environment: Environment | null = await models.environment.getById(environmentId || 'n/a'); + const responseEnvironmentId = environment ? environment._id : null; + + const caCert = await models.caCertificate.findByParentId(options.workspaceId); + const caCertficatePath = caCert?.path; + // attempt to read CA Certificate PEM from disk, fallback to root certificates + const caCertificate = (caCertficatePath && (await fs.promises.readFile(caCertficatePath)).toString()) || tls.rootCertificates.join('\n'); + + try { + if (!options.url) { + throw new Error('URL is required'); + } + const readyStateChannel = `curl.${request._id}.readyState`; + + const settings = await models.settings.getOrCreate(); + const start = performance.now(); + const clientCertificates = await models.clientCertificate.findByParentId(options.workspaceId); + const filteredClientCertificates = clientCertificates.filter(c => !c.disabled && urlMatchesCertHost(setDefaultProtocol(c.host, 'https:'), options.url)); + const { curl, debugTimeline } = createConfiguredCurlInstance({ + // TODO: get cookies here + req: { ...request, cookieJar: options.cookieJar, cookies: [] }, + finalUrl: options.url, + settings, + caCert: caCertificate, + certificates: filteredClientCertificates, + }); + debugTimeline.forEach(entry => timelineFileStreams.get(options.requestId)?.write(JSON.stringify(entry) + '\n')); + curl.enable(CurlFeature.StreamResponse); + curl.setOpt(Curl.option.DEBUGFUNCTION, (infoType, buffer) => { + const isSSLData = infoType === CurlInfoDebug.SslDataIn || infoType === CurlInfoDebug.SslDataOut; + const isEmpty = buffer.length === 0; + // Don't show cookie setting because this will display every domain in the jar + const isAddCookie = infoType === CurlInfoDebug.Text && buffer.toString('utf8').indexOf('Added cookie') === 0; + if (isSSLData || isEmpty || isAddCookie) { + return 0; + } + + // NOTE: resolves "Text" from CurlInfoDebug[CurlInfoDebug.Text] + const name = CurlInfoDebug[infoType] as keyof typeof CurlInfoDebug; + let timelineMessage; + const isRequestData = infoType === CurlInfoDebug.DataOut; + if (isRequestData) { + // Ignore large post data messages + const isLessThan10KB = buffer.length / 1024 < (settings.maxTimelineDataSizeKB || 1); + timelineMessage = isLessThan10KB ? buffer.toString('utf8') : `(${describeByteSize(buffer.length)} hidden)`; + } + const isResponseData = infoType === CurlInfoDebug.DataIn; + if (!isResponseData) { + const value = timelineMessage || buffer.toString('utf8'); + timelineFileStreams.get(options.requestId)?.write(JSON.stringify({ name, value, timestamp: Date.now() }) + '\n'); + } + return 0; + }); + curl.on('error', async (error, errorCode) => { + const errorEvent: CurlErrorEvent = { + _id: uuidV4(), + requestId: options.requestId, + message: error.message, + type: 'error', + error, + timestamp: Date.now(), + }; + console.error('curl - error: ', error, errorCode); + deleteRequestMaps(request._id, error.message, errorEvent); + event.sender.send(readyStateChannel, false); + curl.close(); + if (errorCode) { + const res = await models.response.getById(responseId); + if (!res) { + createErrorResponse(responseId, request._id, responseEnvironmentId, timelinePath, error.message || 'Something went wrong'); + } + } + }); + + curl.on('stream', async (stream: Readable, _code: number, [headersWithStatus]: HeaderInfo[]) => { + event.sender.send(readyStateChannel, true); + const { timeline, responseHeaders, statusCode, statusMessage, httpVersion } = parseHeadersAndBuildTimeline(options.url, headersWithStatus); + + const responsePatch: Partial = { + _id: responseId, + parentId: request._id, + environmentId: responseEnvironmentId, + headers: responseHeaders, + url: options.url, + statusCode, + statusMessage, + httpVersion, + elapsedTime: performance.now() - start, + timelinePath, + bodyPath: responseBodyPath, + settingSendCookies: request.settingSendCookies, + settingStoreCookies: request.settingStoreCookies, + bodyCompression: null, + }; + const settings = await models.settings.getOrCreate(); + const res = await models.response.create(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: res._id }); + + if (request.settingStoreCookies) { + const setCookieStrings: string[] = getSetCookieHeaders(responseHeaders).map(h => h.value); + const totalSetCookies = setCookieStrings.length; + if (totalSetCookies) { + const currentUrl = request.url; + const { cookies, rejectedCookies } = await addSetCookiesToToughCookieJar({ setCookieStrings, currentUrl, cookieJar: options.cookieJar }); + rejectedCookies.forEach(errorMessage => timeline.push({ value: `Rejected cookie: ${errorMessage}`, name: 'Text', timestamp: Date.now() })); + const hasCookiesToPersist = totalSetCookies > rejectedCookies.length; + if (hasCookiesToPersist) { + await models.cookieJar.update(options.cookieJar, { cookies }); + timeline.push({ value: `Saved ${totalSetCookies} cookies`, name: 'Text', timestamp: Date.now() }); + } + } + } + timeline.map(t => timelineFileStreams.get(options.requestId)?.write(JSON.stringify(t) + '\n')); + + // we are going to write the response stream to this file + const writableStream = eventLogFileStreams.get(request._id); + // two ways to go here + invariant(writableStream, 'writableStream should be defined'); + for await (const chunk of stream) { + const messageEvent: CurlMessageEvent = { + _id: uuidV4(), + requestId: options.requestId, + data: new TextDecoder('utf-8').decode(chunk), + type: 'message', + timestamp: Date.now(), + direction: 'INCOMING', + }; + eventLogFileStreams.get(options.requestId)?.write(JSON.stringify(messageEvent) + '\n'); + } + // NOTE: this can only happen if the stream is closed cleanly by the remote server + eventLogFileStreams.get(options.requestId)?.end(); + console.log('response stream: ended'); + event.sender.send(readyStateChannel, false); + }); + curl.perform(); + CurlConnections.set(options.requestId, curl); + } catch (e) { + console.error('unhandled error:', e); + + deleteRequestMaps(request._id, e.message || 'Something went wrong'); + createErrorResponse(responseId, request._id, responseEnvironmentId, timelinePath, e.message || 'Something went wrong'); + } +}; + +const createErrorResponse = async (responseId: string, requestId: string, environmentId: string | null, timelinePath: string, message: string) => { + const settings = await models.settings.getOrCreate(); + const responsePatch = { + _id: responseId, + parentId: requestId, + environmentId: environmentId, + timelinePath, + statusMessage: 'Error', + error: message, + }; + const res = await models.response.create(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id }); +}; + +const deleteRequestMaps = async (requestId: string, message: string, event?: CurlCloseEvent | CurlErrorEvent) => { + if (event) { + eventLogFileStreams.get(requestId)?.write(JSON.stringify(event) + '\n'); + } + eventLogFileStreams.get(requestId)?.end(); + eventLogFileStreams.delete(requestId); + timelineFileStreams.get(requestId)?.write(JSON.stringify({ value: message, name: 'Text', timestamp: Date.now() }) + '\n'); + timelineFileStreams.get(requestId)?.end(); + timelineFileStreams.delete(requestId); + CurlConnections.delete(requestId); +}; + +const getCurlReadyState = async ( + options: { requestId: string } +): Promise => { + return CurlConnections.get(options.requestId)?.isOpen ?? false; +}; + +const closeCurlConnection = ( + event: Electron.IpcMainInvokeEvent, + options: { requestId: string } +): void => { + if (!CurlConnections.get(options.requestId)) { + return; + } + const readyStateChannel = `curl.${options.requestId}.readyState`; + const statusCode = +(CurlConnections.get(options.requestId)?.getInfo(Curl.info.HTTP_CONNECTCODE) || 0); + const closeEvent: CurlCloseEvent = { + _id: uuidV4(), + requestId: options.requestId, + type: 'close', + timestamp: Date.now(), + statusCode, + reason: '', + code: 0, + wasClean: true, + }; + CurlConnections.get(options.requestId)?.close(); + deleteRequestMaps(options.requestId, 'Closing connection', closeEvent); + event.sender.send(readyStateChannel, false); +}; + +const closeAllCurlConnections = (): void => CurlConnections.forEach(curl => curl.close()); + +const findMany = async ( + options: { responseId: string } +): Promise => { + const response = await models.response.getById(options.responseId); + if (!response || !response.bodyPath) { + return []; + } + const body = await fs.promises.readFile(response.bodyPath); + return body.toString().split('\n').filter(e => e?.trim()) + // Parse the message + .map(e => JSON.parse(e)) + // Reverse the list of messages so that we get the latest message first + .reverse() || []; +}; + +export interface CurlBridgeAPI { + open: (options: OpenCurlRequestOptions) => void; + close: (options: { requestId: string }) => void; + closeAll: typeof closeAllCurlConnections; + readyState: { + getCurrent: typeof getCurlReadyState; + }; + event: { + findMany: typeof findMany; + }; +} +export const registerCurlHandlers = () => { + ipcMain.handle('curl.open', openCurlConnection); + ipcMain.on('curl.close', closeCurlConnection); + ipcMain.on('curl.closeAll', closeAllCurlConnections); + ipcMain.handle('curl.readyState', (_, options: Parameters[0]) => getCurlReadyState(options)); + ipcMain.handle('curl.event.findMany', (_, options: Parameters[0]) => findMany(options)); +}; + +electron.app.on('window-all-closed', closeAllCurlConnections); diff --git a/packages/insomnia/src/main/network/libcurl-promise.ts b/packages/insomnia/src/main/network/libcurl-promise.ts index d1d3a9a4b..6f16a8fc0 100644 --- a/packages/insomnia/src/main/network/libcurl-promise.ts +++ b/packages/insomnia/src/main/network/libcurl-promise.ts @@ -98,121 +98,18 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise { - const { passphrase, cert, key, pfx } = validCert; - if (cert) { - curl.setOpt(Curl.option.SSLCERT, cert); - curl.setOpt(Curl.option.SSLCERTTYPE, 'PEM'); - debugTimeline.push({ value: 'Adding SSL PEM certificate', name: 'Text', timestamp: Date.now() }); - } - if (pfx) { - curl.setOpt(Curl.option.SSLCERT, pfx); - curl.setOpt(Curl.option.SSLCERTTYPE, 'P12'); - debugTimeline.push({ value: 'Adding SSL P12 certificate', name: 'Text', timestamp: Date.now() }); - } - if (key) { - curl.setOpt(Curl.option.SSLKEY, key); - debugTimeline.push({ value: 'Adding SSL KEY certificate', name: 'Text', timestamp: Date.now() }); - } - if (passphrase) { - curl.setOpt(Curl.option.KEYPASSWD, passphrase); - } + const { curl, debugTimeline } = createConfiguredCurlInstance({ + req, + finalUrl, + settings, + caCert, + certificates, + socketPath, }); - const httpVersion = getHttpVersion(settings.preferredHttpVersion); - debugTimeline.push({ value: httpVersion.log, name: 'Text', timestamp: Date.now() }); - - if (httpVersion.curlHttpVersion) { - curl.setOpt(Curl.option.HTTP_VERSION, httpVersion.curlHttpVersion); - } - - // Set maximum amount of redirects allowed - // NOTE: Setting this to -1 breaks some versions of libcurl - if (settings.maxRedirects > 0) { - curl.setOpt(Curl.option.MAXREDIRS, settings.maxRedirects); - } - - if (!settings.proxyEnabled) { - curl.setOpt(Curl.option.PROXY, ''); - } else { - const { protocol } = urlParse(req.url); - const { httpProxy, httpsProxy, noProxy } = settings; - const proxyHost = protocol === 'https:' ? httpsProxy : httpProxy; - const proxy = proxyHost ? setDefaultProtocol(proxyHost) : null; - debugTimeline.push({ value: `Enable network proxy for ${protocol || ''}`, name: 'Text', timestamp: Date.now() }); - if (proxy) { - curl.setOpt(Curl.option.PROXY, proxy); - curl.setOpt(Curl.option.PROXYAUTH, CurlAuth.Any); - } - if (noProxy) { - curl.setOpt(Curl.option.NOPROXY, noProxy); - } - } - const { timeout } = settings; - if (timeout <= 0) { - curl.setOpt(Curl.option.TIMEOUT_MS, 0); - } else { - curl.setOpt(Curl.option.TIMEOUT_MS, timeout); - debugTimeline.push({ value: `Enable timeout of ${timeout}ms`, name: 'Text', timestamp: Date.now() }); - } - const { validateSSL } = settings; - if (!validateSSL) { - curl.setOpt(Curl.option.SSL_VERIFYHOST, 0); - curl.setOpt(Curl.option.SSL_VERIFYPEER, 0); - } - debugTimeline.push({ value: `${validateSSL ? 'Enable' : 'Disable'} SSL validation`, name: 'Text', timestamp: Date.now() }); - - const followRedirects = { - 'off': false, - 'on': true, - 'global': settings.followRedirects, - }[req.settingFollowRedirects] ?? true; - - curl.setOpt(Curl.option.FOLLOWLOCATION, followRedirects); - - // Don't rebuild dot sequences in path - if (!req.settingRebuildPath) { - curl.setOpt(Curl.option.PATH_AS_IS, true); - } - - if (req.settingSendCookies) { - const { cookieJar, cookies } = req; - curl.setOpt(Curl.option.COOKIEFILE, ''); - - for (const { name, value } of cookies) { - curl.setOpt(Curl.option.COOKIE, `${name}=${value}`); - } - // set-cookies from previous redirects - if (cookieJar.cookies.length) { - debugTimeline.push({ value: `Enable cookie sending with jar of ${cookieJar.cookies.length} cookie${cookieJar.cookies.length !== 1 ? 's' : ''}`, name: 'Text', timestamp: Date.now() }); - for (const cookie of cookieJar.cookies) { - const setCookie = [ - cookie.httpOnly ? `#HttpOnly_${cookie.domain}` : cookie.domain, - cookie.hostOnly ? 'FALSE' : 'TRUE', - cookie.path, - cookie.secure ? 'TRUE' : 'FALSE', - cookie.expires ? Math.round(new Date(cookie.expires).getTime() / 1000) : 0, - cookie.key, - cookie.value, - ].join('\t'); - curl.setOpt(Curl.option.COOKIELIST, setCookie); - } - } - } const { method, body } = req; // Only set CURLOPT_CUSTOMREQUEST if not HEAD or GET. // See https://curl.haxx.se/libcurl/c/CURLOPT_CUSTOMREQUEST.html @@ -228,7 +125,7 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise new Promise closeReadFunction(requestFileDescriptor, isMultipart, requestBodyPath)); - curl.on('error', () => closeReadFunction(requestFileDescriptor, isMultipart, requestBodyPath)); + curl.on('end', () => closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath)); + curl.on('error', () => closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath)); } else if (requestBody !== undefined) { curl.setOpt(Curl.option.POSTFIELDS, requestBody); } - // suppress node-libcurl default user-agent - curl.setOpt(Curl.option.USERAGENT, ''); - const headerStrings = parseHeaderStrings({ req, requestBody, requestBodyPath, finalUrl, authHeader }); curl.setOpt(Curl.option.HTTPHEADER, headerStrings); - const { headers } = req; - const { username, password, disabled } = authentication; - const isDigest = authentication.type === AUTH_DIGEST; - const isNLTM = authentication.type === AUTH_NTLM; - const isDigestOrNLTM = isDigest || isNLTM; - if (!hasAuthHeader(headers) && !disabled && isDigestOrNLTM) { - isDigest && curl.setOpt(Curl.option.HTTPAUTH, CurlAuth.Digest); - isNLTM && curl.setOpt(Curl.option.HTTPAUTH, CurlAuth.Ntlm); - curl.setOpt(Curl.option.USERNAME, username || ''); - curl.setOpt(Curl.option.PASSWORD, password || ''); - } - if (authentication.type === AUTH_NETRC) { - curl.setOpt(Curl.option.NETRC, CurlNetrc.Required); - } - // Create instance and handlers, poke value options in, set up write and debug callbacks, listen for events const responseBodyWriteStream = fs.createWriteStream(responseBodyPath); // cancel request by id map cancelCurlRequestHandlers[requestId] = () => { if (requestFileDescriptor && responseBodyPath) { - closeReadFunction(requestFileDescriptor, isMultipart, requestBodyPath); + closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath); } curl.close(); }; @@ -281,6 +160,7 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise { + console.log('write', buffer.length); responseBodyBytes += buffer.length; responseBodyWriteStream.write(buffer); return buffer.length; @@ -367,8 +247,155 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise { - fs.closeSync(fd); +export const createConfiguredCurlInstance = ({ + req, + finalUrl, + settings, + caCert, + certificates, + socketPath, +}: { + req: RequestUsedHere; + finalUrl: string; + settings: SettingsUsedHere; + certificates: ClientCertificate[]; + caCert: string; + socketPath?: string; +}) => { + const debugTimeline: ResponseTimelineEntry[] = []; + const curl = new Curl(); + curl.setOpt(Curl.option.URL, finalUrl); + socketPath && curl.setOpt(Curl.option.UNIX_SOCKET_PATH, socketPath); + + curl.setOpt(Curl.option.VERBOSE, true); // Set all the basic options + curl.setOpt(Curl.option.NOPROGRESS, true); // True so debug function works + curl.setOpt(Curl.option.ACCEPT_ENCODING, ''); // True so curl doesn't print progress + // attempt to read CA Certificate PEM from disk, fallback to root certificates + curl.setOpt(Curl.option.CAINFO_BLOB, caCert); + certificates.forEach(validCert => { + const { passphrase, cert, key, pfx } = validCert; + if (cert) { + curl.setOpt(Curl.option.SSLCERT, cert); + curl.setOpt(Curl.option.SSLCERTTYPE, 'PEM'); + debugTimeline.push({ value: 'Adding SSL PEM certificate', name: 'Text', timestamp: Date.now() }); + } + if (pfx) { + curl.setOpt(Curl.option.SSLCERT, pfx); + curl.setOpt(Curl.option.SSLCERTTYPE, 'P12'); + debugTimeline.push({ value: 'Adding SSL P12 certificate', name: 'Text', timestamp: Date.now() }); + } + if (key) { + curl.setOpt(Curl.option.SSLKEY, key); + debugTimeline.push({ value: 'Adding SSL KEY certificate', name: 'Text', timestamp: Date.now() }); + } + if (passphrase) { + curl.setOpt(Curl.option.KEYPASSWD, passphrase); + } + }); + const httpVersion = getHttpVersion(settings.preferredHttpVersion); + debugTimeline.push({ value: httpVersion.log, name: 'Text', timestamp: Date.now() }); + + if (httpVersion.curlHttpVersion) { + curl.setOpt(Curl.option.HTTP_VERSION, httpVersion.curlHttpVersion); + } + + // Set maximum amount of redirects allowed + // NOTE: Setting this to -1 breaks some versions of libcurl + if (settings.maxRedirects > 0) { + curl.setOpt(Curl.option.MAXREDIRS, settings.maxRedirects); + } + + if (!settings.proxyEnabled) { + curl.setOpt(Curl.option.PROXY, ''); + } else { + const { protocol } = urlParse(req.url); + const { httpProxy, httpsProxy, noProxy } = settings; + const proxyHost = protocol === 'https:' ? httpsProxy : httpProxy; + const proxy = proxyHost ? setDefaultProtocol(proxyHost) : null; + debugTimeline.push({ value: `Enable network proxy for ${protocol || ''}`, name: 'Text', timestamp: Date.now() }); + if (proxy) { + curl.setOpt(Curl.option.PROXY, proxy); + curl.setOpt(Curl.option.PROXYAUTH, CurlAuth.Any); + } + if (noProxy) { + curl.setOpt(Curl.option.NOPROXY, noProxy); + } + } + const { timeout } = settings; + if (timeout <= 0) { + curl.setOpt(Curl.option.TIMEOUT_MS, 0); + } else { + curl.setOpt(Curl.option.TIMEOUT_MS, timeout); + debugTimeline.push({ value: `Enable timeout of ${timeout}ms`, name: 'Text', timestamp: Date.now() }); + } + const { validateSSL } = settings; + if (!validateSSL) { + curl.setOpt(Curl.option.SSL_VERIFYHOST, 0); + curl.setOpt(Curl.option.SSL_VERIFYPEER, 0); + } + debugTimeline.push({ value: `${validateSSL ? 'Enable' : 'Disable'} SSL validation`, name: 'Text', timestamp: Date.now() }); + + const followRedirects = { + 'off': false, + 'on': true, + 'global': settings.followRedirects, + }[req.settingFollowRedirects] ?? true; + + curl.setOpt(Curl.option.FOLLOWLOCATION, followRedirects); + + // Don't rebuild dot sequences in path + if (!req.settingRebuildPath) { + curl.setOpt(Curl.option.PATH_AS_IS, true); + } + + if (req.settingSendCookies) { + const { cookieJar, cookies } = req; + curl.setOpt(Curl.option.COOKIEFILE, ''); + + for (const { name, value } of cookies) { + curl.setOpt(Curl.option.COOKIE, `${name}=${value}`); + } + // set-cookies from previous redirects + if (cookieJar.cookies.length) { + debugTimeline.push({ value: `Enable cookie sending with jar of ${cookieJar.cookies.length} cookie${cookieJar.cookies.length !== 1 ? 's' : ''}`, name: 'Text', timestamp: Date.now() }); + for (const cookie of cookieJar.cookies) { + const setCookie = [ + cookie.httpOnly ? `#HttpOnly_${cookie.domain}` : cookie.domain, + cookie.hostOnly ? 'FALSE' : 'TRUE', + cookie.path, + cookie.secure ? 'TRUE' : 'FALSE', + cookie.expires ? Math.round(new Date(cookie.expires).getTime() / 1000) : 0, + cookie.key, + cookie.value, + ].join('\t'); + curl.setOpt(Curl.option.COOKIELIST, setCookie); + } + } + } + + // suppress node-libcurl default user-agent + curl.setOpt(Curl.option.USERAGENT, ''); + const { headers, authentication } = req; + const { username, password, disabled } = authentication; + const isDigest = authentication.type === AUTH_DIGEST; + const isNLTM = authentication.type === AUTH_NTLM; + const isDigestOrNLTM = isDigest || isNLTM; + if (!hasAuthHeader(headers) && !disabled && isDigestOrNLTM) { + isDigest && curl.setOpt(Curl.option.HTTPAUTH, CurlAuth.Digest); + isNLTM && curl.setOpt(Curl.option.HTTPAUTH, CurlAuth.Ntlm); + curl.setOpt(Curl.option.USERNAME, username || ''); + curl.setOpt(Curl.option.PASSWORD, password || ''); + } + if (authentication.type === AUTH_NETRC) { + curl.setOpt(Curl.option.NETRC, CurlNetrc.Required); + } + return { curl, debugTimeline }; +}; + +const closeReadFunction = (isMultipart: boolean, fd?: number, path?: string) => { + if (fd) { + fs.closeSync(fd); + } // NOTE: multipart files are combined before sending, so this file is deleted after // alt implementation to send one part at a time https://github.com/JCMais/node-libcurl/blob/develop/examples/04-multi.js if (isMultipart && path) { diff --git a/packages/insomnia/src/main/network/websocket.ts b/packages/insomnia/src/main/network/websocket.ts index 5c81e7692..836a1a2e9 100644 --- a/packages/insomnia/src/main/network/websocket.ts +++ b/packages/insomnia/src/main/network/websocket.ts @@ -136,6 +136,9 @@ const openWebSocketConnection = async ( const caCertificate = (caCertficatePath && (await fs.promises.readFile(caCertficatePath)).toString()) || tls.rootCertificates.join('\n'); try { + if (!options.url) { + throw new Error('URL is required'); + } const readyStateChannel = `webSocket.${request._id}.readyState`; const reduceArrayToLowerCaseKeyedDictionary = (acc: { [key: string]: string }, { name, value }: BaseWebSocketRequest['headers'][0]) => @@ -249,8 +252,8 @@ const openWebSocketConnection = async ( }; const settings = await models.settings.getOrCreate(); - models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses); - models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: null }); + const res = await models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: res._id }); if (request.settingStoreCookies) { const setCookieStrings: string[] = getSetCookieHeaders(responseHeaders).map(h => h.value); @@ -293,8 +296,8 @@ const openWebSocketConnection = async ( settingStoreCookies: request.settingStoreCookies, }; const settings = await models.settings.getOrCreate(); - models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses); - models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: null }); + const res = await models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: res._id }); deleteRequestMaps(request._id, `Unexpected response ${incomingMessage.statusCode}`); }); @@ -380,8 +383,8 @@ const createErrorResponse = async (responseId: string, requestId: string, enviro statusMessage: 'Error', error: message, }; - models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses); - models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: null }); + const res = await models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id }); }; const deleteRequestMaps = async (requestId: string, message: string, event?: WebSocketCloseEvent | WebSocketErrorEvent) => { diff --git a/packages/insomnia/src/models/request.ts b/packages/insomnia/src/models/request.ts index bacba4cdf..bd8211b53 100644 --- a/packages/insomnia/src/models/request.ts +++ b/packages/insomnia/src/models/request.ts @@ -115,6 +115,10 @@ export const isRequest = (model: Pick): model is Request => ( model.type === type ); +export const isEventStreamRequest = (model: Pick) => ( + isRequest(model) && model.headers?.find(h => h.name === 'Accept')?.value === 'text/event-stream' +); + export function init(): BaseRequest { return { url: '', diff --git a/packages/insomnia/src/models/response.ts b/packages/insomnia/src/models/response.ts index 108cc9fbd..6cb22ad5d 100644 --- a/packages/insomnia/src/models/response.ts +++ b/packages/insomnia/src/models/response.ts @@ -250,6 +250,7 @@ export function getTimeline(response: Response, showBody?: boolean) { const rawBuffer = fs.readFileSync(timelinePath); const timelineString = rawBuffer.toString(); const timeline = JSON.parse(timelineString) as ResponseTimelineEntry[]; + const body: ResponseTimelineEntry[] = showBody ? [ { name: 'DataOut', diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index 65a5b03e4..326d49d39 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import { gRPCBridgeAPI } from './main/ipc/grpc'; +import { CurlBridgeAPI } from './main/network/curl'; import type { WebSocketBridgeAPI } from './main/network/websocket'; const webSocket: WebSocketBridgeAPI = { @@ -15,6 +16,18 @@ const webSocket: WebSocketBridgeAPI = { send: options => ipcRenderer.invoke('webSocket.event.send', options), }, }; +const curl: CurlBridgeAPI = { + open: options => ipcRenderer.invoke('curl.open', options), + close: options => ipcRenderer.send('curl.close', options), + closeAll: () => ipcRenderer.send('curl.closeAll'), + readyState: { + getCurrent: options => ipcRenderer.invoke('curl.readyState', options), + }, + event: { + findMany: options => ipcRenderer.invoke('curl.event.findMany', options), + }, +}; + const grpc: gRPCBridgeAPI = { start: options => ipcRenderer.send('grpc.start', options), sendMessage: options => ipcRenderer.send('grpc.sendMessage', options), @@ -44,6 +57,7 @@ const main: Window['main'] = { }, webSocket, grpc, + curl, trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options), trackPageView: options => ipcRenderer.send('trackPageView', options), axiosRequest: options => ipcRenderer.invoke('axiosRequest', options), diff --git a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx index e0f07f828..69476d792 100644 --- a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx @@ -95,6 +95,7 @@ export interface CodeEditorProps { onBlur?: () => void; onChange?: (value: string) => void; onClickLink?: CodeMirrorLinkClickCallback; + pinToBottom?: boolean; placeholder?: string; readOnly?: boolean; style?: Object; @@ -159,6 +160,7 @@ export const CodeEditor = forwardRef(({ onBlur, onChange, onClickLink, + pinToBottom, placeholder, readOnly, style, @@ -320,6 +322,19 @@ export const CodeEditor = forwardRef(({ // Don't allow non-breaking spaces because they break the GraphQL syntax change.update?.(change.from, change.to, change.text.map(normalizeIrregularWhitespace)); } + if (pinToBottom) { + const scrollInfo = doc.getScrollInfo(); + const scrollPosition = scrollInfo.height - scrollInfo.clientHeight; + doc.scrollTo(0, scrollPosition); + } + }); + + codeMirror.current.on('change', (doc: CodeMirror.Editor) => { + if (pinToBottom) { + const scrollInfo = doc.getScrollInfo(); + const scrollPosition = scrollInfo.height - scrollInfo.clientHeight; + doc.scrollTo(0, scrollPosition); + } }); codeMirror.current.on('keydown', (_: CodeMirror.Editor, event: KeyboardEvent) => { diff --git a/packages/insomnia/src/ui/components/editors/body/body-editor.tsx b/packages/insomnia/src/ui/components/editors/body/body-editor.tsx index 444edfb9f..70c8d35d4 100644 --- a/packages/insomnia/src/ui/components/editors/body/body-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/body/body-editor.tsx @@ -12,9 +12,10 @@ import { import { documentationLinks } from '../../../../common/documentation'; import { getContentTypeHeader } from '../../../../common/misc'; import * as models from '../../../../models'; -import type { - Request, - RequestBodyParameter, +import { + isEventStreamRequest, + type Request, + type RequestBodyParameter, } from '../../../../models/request'; import type { Workspace } from '../../../../models/workspace'; import { NunjucksEnabledProvider } from '../../../context/nunjucks/nunjucks-enabled-context'; @@ -135,9 +136,14 @@ export const BodyEditor: FC = ({ } else if (!isBodyEmpty) { const contentType = getContentTypeFromHeaders(request.headers) || mimeType; return ; - } else { - return } documentationLinks={[documentationLinks.introductionToInsomnia]} secondaryAction="Select a body type from above to send data in the body of a request" title="Enter a URL and send to get a response" />; + } else if (isEventStreamRequest(request)) { + return } + documentationLinks={[]} + title="Enter a URL and connect to start receiving event stream data" + />; } + return } documentationLinks={[documentationLinks.introductionToInsomnia]} secondaryAction="Select a body type from above to send data in the body of a request" title="Enter a URL and send to get a response" />; }; return {_render()}; diff --git a/packages/insomnia/src/ui/components/panes/empty-state-pane.tsx b/packages/insomnia/src/ui/components/panes/empty-state-pane.tsx index 3d58f6733..20e94e064 100644 --- a/packages/insomnia/src/ui/components/panes/empty-state-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/empty-state-pane.tsx @@ -78,7 +78,7 @@ const LinkIcon = styled(SvgIcon)({ export const EmptyStatePane: FC<{ icon: ReactNode; title: string; - secondaryAction: ReactNode; + secondaryAction?: ReactNode; documentationLinks: { title: string; url: string; @@ -88,23 +88,22 @@ export const EmptyStatePane: FC<{ title, secondaryAction, documentationLinks, -}) => { - return ( - - - {icon} - {title} +}) => ( + + {icon} + {title} + {Boolean(secondaryAction) && + (<> {secondaryAction} - - {documentationLinks.map(({ title, url }) => ( - - {title} - - - ))} - - - - ); -}; + )} + + {documentationLinks.map(({ title, url }) => ( + + {title} + + + ))} + + +); diff --git a/packages/insomnia/src/ui/components/request-url-bar.tsx b/packages/insomnia/src/ui/components/request-url-bar.tsx index b9c9ace54..fc20c3267 100644 --- a/packages/insomnia/src/ui/components/request-url-bar.tsx +++ b/packages/insomnia/src/ui/components/request-url-bar.tsx @@ -9,15 +9,19 @@ import styled from 'styled-components'; import { database } from '../../common/database'; import { getContentDispositionHeader } from '../../common/misc'; +import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../common/render'; import * as models from '../../models'; import { update } from '../../models/helpers/request-operations'; -import { isRequest, Request } from '../../models/request'; +import { isEventStreamRequest, isRequest, Request } from '../../models/request'; import * as network from '../../network/network'; import { convert } from '../../utils/importers/convert'; +import { invariant } from '../../utils/invariant'; +import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../utils/url/querystring'; import { SegmentEvent } from '../analytics'; import { updateRequestMetaByParentId } from '../hooks/create-request'; +import { useCurlReadyState } from '../hooks/use-ready-state'; import { useTimeoutWhen } from '../hooks/useTimeoutWhen'; -import { selectActiveEnvironment, selectActiveRequest, selectHotKeyRegistry, selectResponseDownloadPath, selectSettings } from '../redux/selectors'; +import { selectActiveEnvironment, selectActiveRequest, selectActiveWorkspace, selectHotKeyRegistry, selectResponseDownloadPath, selectSettings } from '../redux/selectors'; import { Dropdown, DropdownButton, type DropdownHandle, DropdownItem, DropdownSection, ItemContent } from './base/dropdown'; import { OneLineEditor, OneLineEditorHandle } from './codemirror/one-line-editor'; import { MethodDropdown } from './dropdowns/method-dropdown'; @@ -59,6 +63,7 @@ export const RequestUrlBar = forwardRef(({ const downloadPath = useSelector(selectResponseDownloadPath); const hotKeyRegistry = useSelector(selectHotKeyRegistry); const activeEnvironment = useSelector(selectActiveEnvironment); + const activeWorkspace = useSelector(selectActiveWorkspace); const activeRequest = useSelector(selectActiveRequest); const settings = useSelector(selectSettings); const methodDropdownRef = useRef(null); @@ -232,14 +237,45 @@ export const RequestUrlBar = forwardRef(({ setCurrentTimeout(undefined); if (downloadPath) { sendThenSetFilePath(downloadPath); - } else { - handleSend(); + return; } - }, [downloadPath, handleSend, sendThenSetFilePath]); + if (isEventStreamRequest(request)) { + const startListening = async () => { + invariant(activeWorkspace, 'activeWorkspace not found (remove with redux)'); + const environmentId = activeEnvironment?._id; + const workspaceId = activeWorkspace._id; + const renderContext = await getRenderContext({ request, environmentId, purpose: RENDER_PURPOSE_SEND }); + // Render any nunjucks tags in the url/headers/authentication settings/cookies + const workspaceCookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId); + const rendered = await render({ + url: request.url, + headers: request.headers, + authentication: request.authentication, + parameters: request.parameters.filter(p => !p.disabled), + workspaceCookieJar, + }, renderContext); + window.main.curl.open({ + requestId: request._id, + workspaceId, + url: joinUrlAndQueryString(rendered.url, buildQueryStringFromParams(rendered.parameters)), + headers: rendered.headers, + authentication: rendered.authentication, + cookieJar: rendered.workspaceCookieJar, + }); + }; + startListening(); + return; + } + handleSend(); + }, [activeEnvironment?._id, activeWorkspace, downloadPath, handleSend, request, sendThenSetFilePath]); useInterval(send, currentInterval ? currentInterval : null); useTimeoutWhen(send, currentTimeout, !!currentTimeout); const handleStop = () => { + if (isEventStreamRequest(request)) { + window.main.curl.close({ requestId: request._id }); + return; + } setCurrentInterval(null); setCurrentTimeout(undefined); }; @@ -318,10 +354,7 @@ export const RequestUrlBar = forwardRef(({ body: r.body, authentication: r.authentication, parameters: r.parameters, - }, - // Pass true to indicate that this is an import - true - ); + }, true); // Pass true to indicate that this is an import } } catch (error) { // Import failed, that's alright @@ -357,9 +390,10 @@ export const RequestUrlBar = forwardRef(({ const handleSendDropdownHide = useCallback(() => { buttonRef.current?.blur(); }, []); - + const buttonText = isEventStreamRequest(request) ? 'Connect' : (downloadPath ? 'Download' : 'Send'); const { url, method } = request; - const isCancellable = currentInterval || currentTimeout; + const isEventStreamOpen = useCurlReadyState(request._id); + const isCancellable = currentInterval || currentTimeout || isEventStreamOpen; return (
(({ {isCancellable ? ( ) : ( <> @@ -397,9 +431,8 @@ export const RequestUrlBar = forwardRef(({ className="urlbar__send-btn" onClick={send} > - {downloadPath ? 'Download' : 'Send'} - - + {isEventStreamRequest(request) ? null : ((({ /> - + )} )}
diff --git a/packages/insomnia/src/ui/components/sidebar/sidebar-create-dropdown.tsx b/packages/insomnia/src/ui/components/sidebar/sidebar-create-dropdown.tsx index f766aab5b..9813ee15c 100644 --- a/packages/insomnia/src/ui/components/sidebar/sidebar-create-dropdown.tsx +++ b/packages/insomnia/src/ui/components/sidebar/sidebar-create-dropdown.tsx @@ -44,7 +44,6 @@ export const SidebarCreateDropdown = () => { > { /> + + create('Event Stream')} + /> + + = forwardRef(({ methodTag = ; } else if (isWebSocketRequest(request)) { methodTag = ; + } else if (isEventStreamRequest(request)) { + methodTag = (
+ SSE +
); } else if (isRequest(request)) { methodTag = ; } @@ -276,9 +280,10 @@ export const _SidebarRequestRow: FC = forwardRef(({ /> )} /> - {isWebSocketRequest(request) && ( + {isWebSocketRequest(request) ? - )} + : + }
@@ -331,3 +336,8 @@ const WebSocketSpinner = ({ requestId }: { requestId: string }) => { const readyState = useWSReadyState(requestId); return readyState === ReadyState.OPEN ? : null; }; + +const EventStreamSpinner = ({ requestId }: { requestId: string }) => { + const readyState = useCurlReadyState(requestId); + return readyState ? : null; +}; diff --git a/packages/insomnia/src/ui/components/viewers/response-timeline-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-timeline-viewer.tsx index d2040a054..09158b120 100644 --- a/packages/insomnia/src/ui/components/viewers/response-timeline-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-timeline-viewer.tsx @@ -5,9 +5,10 @@ import { CodeEditor, CodeEditorHandle } from '../codemirror/code-editor'; interface Props { timeline: ResponseTimelineEntry[]; + pinToBottom?: boolean; } -export const ResponseTimelineViewer: FC = ({ timeline }) => { +export const ResponseTimelineViewer: FC = ({ timeline, pinToBottom }) => { const editorRef = useRef(null); const rows = timeline .map(({ name, value }, i, all) => { @@ -49,6 +50,7 @@ export const ResponseTimelineViewer: FC = ({ timeline }) => { defaultValue={rows} className="pad-left" mode="curl" + pinToBottom={pinToBottom} /> ); }; diff --git a/packages/insomnia/src/ui/components/viewers/response-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx index 92fbf2e66..30c8bf76f 100644 --- a/packages/insomnia/src/ui/components/viewers/response-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx @@ -254,8 +254,7 @@ export const ResponseViewer = ({ body={getBodyAsString()} key={disableHtmlPreviewJs ? 'no-js' : 'yes-js'} url={url} - webpreferences={`disableDialogs=true, javascript=${disableHtmlPreviewJs ? 'no' : 'yes' - }`} + webpreferences={`disableDialogs=true, javascript=${disableHtmlPreviewJs ? 'no' : 'yes'}`} /> ); } diff --git a/packages/insomnia/src/ui/components/websockets/action-bar.tsx b/packages/insomnia/src/ui/components/websockets/action-bar.tsx index 6ebfb395e..954612c4c 100644 --- a/packages/insomnia/src/ui/components/websockets/action-bar.tsx +++ b/packages/insomnia/src/ui/components/websockets/action-bar.tsx @@ -5,7 +5,7 @@ import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../../common/r import * as models from '../../../models'; import { WebSocketRequest } from '../../../models/websocket-request'; import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../../utils/url/querystring'; -import { ReadyState } from '../../context/websocket-client/use-ws-ready-state'; +import { ReadyState } from '../../hooks/use-ready-state'; import { OneLineEditor, OneLineEditorHandle } from '../codemirror/one-line-editor'; import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder'; import { showAlert, showModal } from '../modals'; diff --git a/packages/insomnia/src/ui/components/websockets/event-log-view.tsx b/packages/insomnia/src/ui/components/websockets/event-log-view.tsx index 9cd6a8dcc..24bb4aa8d 100644 --- a/packages/insomnia/src/ui/components/websockets/event-log-view.tsx +++ b/packages/insomnia/src/ui/components/websockets/event-log-view.tsx @@ -4,6 +4,7 @@ import React, { FC, useRef } from 'react'; import { useMeasure } from 'react-use'; import styled from 'styled-components'; +import { CurlEvent } from '../../../main/network/curl'; import { WebSocketEvent } from '../../../main/network/websocket'; import { SvgIcon, SvgIconProps } from '../svg-icon'; @@ -13,9 +14,9 @@ const Timestamp: FC<{ time: Date | number }> = ({ time }) => { }; interface Props { - events: WebSocketEvent[]; + events: WebSocketEvent[] | CurlEvent[]; selectionId?: string; - onSelect: (event: WebSocketEvent) => void; + onSelect: (event: WebSocketEvent | CurlEvent) => void; } const Divider = styled('div')({ @@ -79,7 +80,7 @@ const EventIconCell = styled('div')({ padding: 'var(--padding-xs)', }); -function getIcon(event: WebSocketEvent): SvgIconProps['icon'] { +function getIcon(event: WebSocketEvent | CurlEvent): SvgIconProps['icon'] { switch (event.type) { case 'message': { if (event.direction === 'OUTGOING') { @@ -111,7 +112,7 @@ const EventMessageCell = styled('div')({ padding: 'var(--padding-xs)', }); -const getMessage = (event: WebSocketEvent): string => { +const getMessage = (event: WebSocketEvent | CurlEvent): string => { switch (event.type) { case 'message': { if ('data' in event && typeof event.data === 'object') { diff --git a/packages/insomnia/src/ui/components/websockets/event-view.tsx b/packages/insomnia/src/ui/components/websockets/event-view.tsx index 88cfeaca3..7312115e4 100644 --- a/packages/insomnia/src/ui/components/websockets/event-view.tsx +++ b/packages/insomnia/src/ui/components/websockets/event-view.tsx @@ -4,6 +4,7 @@ import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW, PREVIEW_MODE_SOURCE, PreviewMode } from '../../../common/constants'; +import { CurlEvent, CurlMessageEvent } from '../../../main/network/curl'; import { WebSocketEvent, WebSocketMessageEvent } from '../../../main/network/websocket'; import { requestMeta } from '../../../models'; import { selectResponsePreviewMode } from '../../redux/selectors'; @@ -36,7 +37,7 @@ const PreviewPaneContents = styled.div({ flexGrow: 1, }); -export const MessageEventView: FC> = ({ event, requestId }) => { +export const MessageEventView: FC> = ({ event, requestId }) => { let raw = event.data.toString(); // Best effort to parse the binary data as a string @@ -108,38 +109,38 @@ export const MessageEventView: FC> = ({ event, requ {previewMode === PREVIEW_MODE_FRIENDLY && - } + } {previewMode === PREVIEW_MODE_SOURCE && - } + } {previewMode === PREVIEW_MODE_RAW && - } + } ); }; -export const EventView: FC> = ({ event, ...props }) => { +export const EventView: FC> = ({ event, ...props }) => { switch (event.type) { case 'message': - return ; + return ; default: return null; } diff --git a/packages/insomnia/src/ui/components/websockets/websocket-response-pane.tsx b/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx similarity index 85% rename from packages/insomnia/src/ui/components/websockets/websocket-response-pane.tsx rename to packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx index 751af4a5c..796651725 100644 --- a/packages/insomnia/src/ui/components/websockets/websocket-response-pane.tsx +++ b/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx @@ -4,16 +4,18 @@ import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { getSetCookieHeaders } from '../../../common/misc'; +import { CurlEvent } from '../../../main/network/curl'; import { ResponseTimelineEntry } from '../../../main/network/libcurl-promise'; import { WebSocketEvent } from '../../../main/network/websocket'; +import { Response } from '../../../models/response'; import { WebSocketResponse } from '../../../models/websocket-response'; -import { useWebSocketConnectionEvents } from '../../context/websocket-client/use-ws-connection-events'; +import { useRealtimeConnectionEvents } from '../../hooks/use-realtime-connection-events'; import { selectActiveResponse } from '../../redux/selectors'; import { PanelContainer, TabItem, Tabs } from '../base/tabs'; import { ResponseHistoryDropdown } from '../dropdowns/response-history-dropdown'; import { ErrorBoundary } from '../error-boundary'; -import { EmptyStatePane } from '../panes/empty-state-pane'; import { Pane, PaneHeader as OriginalPaneHeader } from '../panes/pane'; +import { PlaceholderResponsePane } from '../panes/placeholder-response-pane'; import { SvgIcon } from '../svg-icon'; import { SizeTag } from '../tags/size-tag'; import { StatusTag } from '../tags/status-tag'; @@ -82,46 +84,36 @@ const PaddedButton = styled('button')({ padding: 'var(--padding-sm)', }); -export const WebSocketResponsePane: FC<{ requestId: string }> = +export const RealtimeResponsePane: FC<{ requestId: string }> = ({ requestId, }) => { - const response = useSelector(selectActiveResponse) as WebSocketResponse | null; + const response = useSelector(selectActiveResponse) as WebSocketResponse | Response | null; if (!response) { return ( - } - documentationLinks={[ - { - title: 'Introduction to WebSockets in Insomnia', - url: 'https://docs.insomnia.rest/insomnia/requests', - }, - ]} - title="Enter a URL and connect to a WebSocket server to start sending data" - secondaryAction="Select a payload type to send data to the connection" - /> + ); } - return ; + return ; }; -const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketResponse }> = ({ +const RealtimeActiveResponsePane: FC<{ requestId: string; response: WebSocketResponse | Response }> = ({ requestId, response, }) => { - const [selectedEvent, setSelectedEvent] = useState(null); + const [selectedEvent, setSelectedEvent] = useState(null); const [timeline, setTimeline] = useState([]); - const searchInputRef = useRef(null); const [clearEventsBefore, setClearEventsBefore] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [eventType, setEventType] = useState(); - const allEvents = useWebSocketConnectionEvents({ responseId: response._id }); - const handleSelection = (event: WebSocketEvent) => { - setSelectedEvent((selected: WebSocketEvent | null) => selected?._id === event._id ? null : event); + const [eventType, setEventType] = useState(); + const protocol = response.type === 'WebSocketResponse' ? 'webSocket' : 'curl'; + const allEvents = useRealtimeConnectionEvents({ responseId: response._id, protocol }); + const handleSelection = (event: CurlEvent | WebSocketEvent) => { + setSelectedEvent((selected: CurlEvent | WebSocketEvent | null) => selected?._id === event._id ? null : event); }; const events = allEvents.filter(event => { @@ -163,12 +155,12 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe useEffect(() => { let isMounted = true; const fn = async () => { - // @TODO: this needs to fs.watch or tail the file, instead of reading the whole thing on every event. - // or alternatively a throttle to keep it from reading too frequently const rawBuffer = await fs.promises.readFile(response.timelinePath); const timelineString = rawBuffer.toString(); const timelineParsed = timelineString.split('\n').filter(e => e?.trim()).map(e => JSON.parse(e)); - isMounted && setTimeline(timelineParsed); + if (isMounted) { + setTimeline(timelineParsed); + } }; fn(); return () => { @@ -191,7 +183,7 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe className="tall pane__header__right" /> - + {response.error ? @@ -205,7 +197,7 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe gap: 'var(--padding-sm)', }} > - setEventType(e.currentTarget.value as CurlEvent['type'])}> @@ -304,6 +296,7 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe diff --git a/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx b/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx index a1832dbf2..3aefa3940 100644 --- a/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx +++ b/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx @@ -8,7 +8,7 @@ import * as models from '../../../models'; import { Environment } from '../../../models/environment'; import { WebSocketRequest } from '../../../models/websocket-request'; import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../../utils/url/querystring'; -import { ReadyState, useWSReadyState } from '../../context/websocket-client/use-ws-ready-state'; +import { ReadyState, useWSReadyState } from '../../hooks/use-ready-state'; import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version'; import { selectActiveRequestMeta, selectSettings } from '../../redux/selectors'; import { TabItem, Tabs } from '../base/tabs'; @@ -36,19 +36,18 @@ const SendMessageForm = styled.form({ position: 'relative', boxSizing: 'border-box', }); -const SendButton = styled.button<{ isConnected: boolean }>(({ isConnected }) => - ({ - padding: '0 var(--padding-md)', - marginLeft: 'var(--padding-xs)', - height: '100%', - border: '1px solid var(--hl-lg)', - borderRadius: 'var(--radius-md)', - background: isConnected ? 'var(--color-surprise)' : 'inherit', - color: isConnected ? 'var(--color-font-surprise)' : 'inherit', - ':hover': { - filter: 'brightness(0.8)', - }, - })); +const SendButton = styled.button<{ isConnected: boolean }>(({ isConnected }) => ({ + padding: '0 var(--padding-md)', + marginLeft: 'var(--padding-xs)', + height: '100%', + border: '1px solid var(--hl-lg)', + borderRadius: 'var(--radius-md)', + background: isConnected ? 'var(--color-surprise)' : 'inherit', + color: isConnected ? 'var(--color-font-surprise)' : 'inherit', + ':hover': { + filter: 'brightness(0.8)', + }, +})); const PaneSendButton = styled.div({ display: 'flex', diff --git a/packages/insomnia/src/ui/hooks/create-request.ts b/packages/insomnia/src/ui/hooks/create-request.ts index 0e4796f7c..c882eead6 100644 --- a/packages/insomnia/src/ui/hooks/create-request.ts +++ b/packages/insomnia/src/ui/hooks/create-request.ts @@ -1,4 +1,5 @@ import { + CONTENT_TYPE_EVENT_STREAM, CONTENT_TYPE_GRAPHQL, CONTENT_TYPE_JSON, METHOD_GET, @@ -46,7 +47,7 @@ export const setActiveRequest = async ( }); }; -export type CreateRequestType = 'HTTP' | 'gRPC' | 'GraphQL' | 'WebSocket'; +export type CreateRequestType = 'HTTP' | 'gRPC' | 'GraphQL' | 'WebSocket' | 'Event Stream'; type RequestCreator = (input: { parentId: string; requestType: CreateRequestType; @@ -89,6 +90,24 @@ export const createRequest: RequestCreator = async ({ break; } + case 'Event Stream': { + const request = await models.request.create({ + parentId, + method: METHOD_GET, + url: 'http://localhost:4010/events', + headers: [ + { + name: 'Accept', + value: CONTENT_TYPE_EVENT_STREAM, + }, + ], + name: 'New Event Stream', + }); + models.stats.incrementCreatedRequests(); + setActiveRequest(request._id, workspaceId); + break; + } + case 'HTTP': { const request = await models.request.create({ parentId, diff --git a/packages/insomnia/src/ui/context/websocket-client/use-ws-ready-state.ts b/packages/insomnia/src/ui/hooks/use-ready-state.ts similarity index 55% rename from packages/insomnia/src/ui/context/websocket-client/use-ws-ready-state.ts rename to packages/insomnia/src/ui/hooks/use-ready-state.ts index f3cc838eb..4700aac39 100644 --- a/packages/insomnia/src/ui/context/websocket-client/use-ws-ready-state.ts +++ b/packages/insomnia/src/ui/hooks/use-ready-state.ts @@ -30,3 +30,28 @@ export function useWSReadyState(requestId: string): ReadyState { return readyState; } + +export function useCurlReadyState(requestId: string): boolean { + const [readyState, setReadyState] = useState(false); + + useEffect(() => { + let isMounted = true; + window.main.curl.readyState.getCurrent({ requestId }) + .then((currentReadyState: boolean) => { + isMounted && setReadyState(currentReadyState); + }); + + const unsubscribe = window.main.on(`curl.${requestId}.readyState`, + (_, incomingReadyState: boolean) => { + isMounted && setReadyState(incomingReadyState); + }); + + return () => { + isMounted = false; + unsubscribe(); + }; + + }, [requestId]); + + return readyState; +} diff --git a/packages/insomnia/src/ui/context/websocket-client/use-ws-connection-events.ts b/packages/insomnia/src/ui/hooks/use-realtime-connection-events.ts similarity index 53% rename from packages/insomnia/src/ui/context/websocket-client/use-ws-connection-events.ts rename to packages/insomnia/src/ui/hooks/use-realtime-connection-events.ts index 8e376a84e..3893e56a4 100644 --- a/packages/insomnia/src/ui/context/websocket-client/use-ws-connection-events.ts +++ b/packages/insomnia/src/ui/hooks/use-realtime-connection-events.ts @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; import { useInterval } from 'react-use'; -import { WebSocketEvent } from '../../../main/network/websocket'; +import { CurlEvent } from '../../main/network/curl'; +import { WebSocketEvent } from '../../main/network/websocket'; -export function useWebSocketConnectionEvents({ responseId }: { responseId: string }) { - const [events, setEvents] = useState([]); +export function useRealtimeConnectionEvents({ responseId, protocol }: { responseId: string; protocol: 'curl' | 'webSocket' }) { + const [events, setEvents] = useState([]); useEffect(() => { setEvents([]); @@ -14,7 +15,7 @@ export function useWebSocketConnectionEvents({ responseId }: { responseId: strin () => { let isMounted = true; const fn = async () => { - const allEvents = await window.main.webSocket.event.findMany({ responseId }); + const allEvents = await window.main[protocol].event.findMany({ responseId }); if (isMounted) { setEvents(allEvents); } diff --git a/packages/insomnia/src/ui/routes/debug.tsx b/packages/insomnia/src/ui/routes/debug.tsx index 97c0d6798..470ca8427 100644 --- a/packages/insomnia/src/ui/routes/debug.tsx +++ b/packages/insomnia/src/ui/routes/debug.tsx @@ -9,7 +9,7 @@ import * as models from '../../models'; import { isGrpcRequest } from '../../models/grpc-request'; import { getByParentId as getGrpcRequestMetaByParentId } from '../../models/grpc-request-meta'; import * as requestOperations from '../../models/helpers/request-operations'; -import { isRequest } from '../../models/request'; +import { isEventStreamRequest, isRequest } from '../../models/request'; import { getByParentId as getRequestMetaByParentId } from '../../models/request-meta'; import { isWebSocketRequest } from '../../models/websocket-request'; import { invariant } from '../../utils/invariant'; @@ -34,8 +34,8 @@ import { ResponsePane } from '../components/panes/response-pane'; import { SidebarChildren } from '../components/sidebar/sidebar-children'; import { SidebarFilter } from '../components/sidebar/sidebar-filter'; import { SidebarLayout } from '../components/sidebar-layout'; +import { RealtimeResponsePane } from '../components/websockets/realtime-response-pane'; import { WebSocketRequestPane } from '../components/websockets/websocket-request-pane'; -import { WebSocketResponsePane } from '../components/websockets/websocket-response-pane'; import { updateRequestMetaByParentId } from '../hooks/create-request'; import { createRequestGroup } from '../hooks/create-request-group'; import { @@ -112,8 +112,8 @@ export const Debug: FC = () => { }; const grpcState = grpcStates.find(s => s.requestId === activeRequest?._id); - const setGrpcState = (newState:GrpcRequestState) => setGrpcStates(state => state.map(s => s.requestId === activeRequest?._id ? newState : s)); - const reloadRequests = (requestIds:string[]) => { + const setGrpcState = (newState: GrpcRequestState) => setGrpcStates(state => state.map(s => s.requestId === activeRequest?._id ? newState : s)); + const reloadRequests = (requestIds: string[]) => { setGrpcStates(state => state.map(s => requestIds.includes(s.requestId) ? { ...s, reloadMethods: true } : s)); }; useEffect(() => window.main.on('grpc.start', (_, id) => { @@ -123,11 +123,13 @@ export const Debug: FC = () => { setGrpcStates(state => state.map(s => s.requestId === id ? { ...s, running: false } : s)); }), []); useEffect(() => window.main.on('grpc.data', (_, id, value) => { - setGrpcStates(state => state.map(s => s.requestId === id ? { ...s, responseMessages: [...s.responseMessages, { - id: generateId(), - text: JSON.stringify(value), - created: Date.now(), - }] } : s)); + setGrpcStates(state => state.map(s => s.requestId === id ? { + ...s, responseMessages: [...s.responseMessages, { + id: generateId(), + text: JSON.stringify(value), + created: Date.now(), + }], + } : s)); }), []); useEffect(() => window.main.on('grpc.error', (_, id, error) => { setGrpcStates(state => state.map(s => s.requestId === id ? { ...s, error } : s)); @@ -243,7 +245,7 @@ export const Debug: FC = () => { window.main.grpc.closeAll(); }; }, [activeEnvironment?._id]); - + const isRealtimeRequest = activeRequest && (isWebSocketRequest(activeRequest) || isEventStreamRequest(activeRequest)); return ( @@ -300,9 +302,9 @@ export const Debug: FC = () => { {activeRequest && isGrpcRequest(activeRequest) && grpcState && ( )} - {activeRequest && isWebSocketRequest(activeRequest) && ( - )} - {activeRequest && isRequest(activeRequest) && ( + {isRealtimeRequest && ( + )} + {activeRequest && isRequest(activeRequest) && !isRealtimeRequest && ( )} } />