Initial WebSocket support (#5043)

* [INS-1697] Create WebSocket Request (#5041)

* add fail safe

Co-authored-by: Mark Kim <mark.kim@konghq.com>

* Websockets IPC API (#5044)

* add url to ws-request model

* fix webSocketRequest typo and add url

* add websocket api and expose it through preload ipc

* add typings

Co-authored-by: Mark Kim <mark.kim@konghq.com>

* [INS-1701] Create/Close Websocket Connection (#5046)

* add websocket action bar and its components

* remove comments

* clean up

* reflect on the electron api

* remove unused files

* import name change

* add styling

* remove commet

* add suggested changes

* remove default value

* Add WebSocketRequest to sidebar and update types (#5048)

* [INS-1700] Delete Websocket Request (#5055)

* add websocket actions dropdown

* add actions to websocket request in sidebar

* [INS-1703] Display WebSocket messages - first pass (#5054)

* Update event types to improve inference

* Update websocket response pane to fetch/subscribe and display the events

* [INS-1693] Add WS echo server for smoke tests (#5050)

Co-authored-by: Dimitri Mitropoulos <dimitrimitropoulos@gmail.com>

* fix sidebar unit tests (#5064)

* [INS-1776] adds `ws` dependency explicitly (#5066)

* [INS-1702] WebSocket Send Message (#5052)

* add initial changes for websocket message

* add abstraction for db operation and websocket operation

* remove console

* add rename

* add basic testing

* add basic testing

* add unit tests

* add form event type

* add comments

* disallow exporting context directly

* add suggested changes

* refresh the query

* using useDeepCompareEffect

* rename variable

* add mock

* clean up

* clean up

* correct the file name

* add some changes

* removing nedb-context and its hooks

* remove database changes for event sending

* [INS-1778] Fix Global Module Typing Issue (#5065)

* fix typings

* add jest import

* [INS-1703] View WebSocket Messages (#5074)

* save changes

* add styles and move files around

* remove unused code

* clean up some components

* add clean up

* add timestamp component

* add unit tests

* add case

* add style changes

* [INS-1786] WebSocket headers tab (#5080)

* first ui pass

* extract and wire up bulk editor

* raise ready state, move send

* add upgrade header debug logs

* can pass header to websocket upgrade

* implement readOnly headers

* add upgrade event and sent headers

* clean up

* fill out http upgrade into the event

* change upgrade message

* read only headers while connected

* remove upgrade event

* revert bulk editor change

* fix header editable toggle

* add nunjucks todos

* improve readOnly implementation

* disable codemirror/nunjucks for websocket headers

* take calculated headers out of the data model

* move hardcoded default headers down the tree

* fix request url rendering issue

* removed spammy warning

* clarify prop name

* refine pair typing

* change placeholder

* remove readOnly header property

* fix readOnly header layout

* Update packages/insomnia/src/ui/components/dropdowns/websocket-request-actions-dropdown.tsx

* Show HTTP->WS upgrade (handshake) (#5091)

* first pass as event

* add handshake ui

* add timeline tab

* simplify ResponseTimelineViewer

* transform res debug modal to change timeline props

* decouple timeline fetching from timeline component

* timeline ui pass

* record headers in request and response models

* can view timeline history

* write timeline to file

* some timeline

* can persist event logs

* put interface beside usage

* add note

* add event log history

* remove table event row

* tidying up

* make ws colors match

* enable multiple open connections

* close open connections at app exit

* remove old test

* Update packages/insomnia/src/models/request-version.ts

* fix type

* default readystate

* fix preview css scroll

Co-authored-by: James Gatz <jamesgatzos@gmail.com>

* INS-1788: Add control flow to improve responsiveness. (#5094)

* INS-1788: Add control flow for responsiveness.

This prevents events from flooding the UI thread entirely.

* Add additional code comments.

* fixup: webSocketEvent -> webSocketEvents

* display response headers (#5102)

* Show errors in timeline (#5100)

* remove unused context provider

* show errors in event tab

* updates timeline with message and close

* clean up

* show errors in timeline

* fix WebSocket capitalisation

* make timeline reader specific to ws

* write outbound messages to the timeline

* fix type

* Make the head row sticky in the events table (#5103)

* Add client certificate support to websocket connections (#5112)

* [INS-1810] close connection on response change (#5104)

* close ws connection when response is changed

* add delete logic for the queue mapg

* set error response to active response

* useRef for CodeEditor

* extract closeRequest

* use requestId to eliminate inconsistencies

* refactor extract clean up methods

* timeline feedback

* change type annotation

* fix type

* Revert "use requestId to eliminate inconsistencies"

This reverts commit 98335a927e.

* [INS-1803] show cookie tab in response pane (#5105)

* hard code sending and storing to true

* make tab naming consistent

* hard code cookie settings to false

* fix headers isDisabled bug

* [INS-1805] Add Auth Header Tab (#5115)

* add minimal change to the auth flow

* add disable state

* adding dropdown disable

* simplify reducer

* fix lint

* [INS-1839] Rename tab Header everywhere (#5119)

* can import/export websocket requests (#5122)

* use responseId for timeline name (#5124)

* add react-virtual to virtualize the event log view (#5126)

* [INS-1833] Include Auth Header in Headers mapping for WebSocket Connection (#5120)

* add auth to the header

* remove console log

* remove unneeded async

* add success redirect logic to websocket server

* add unexpected-response handler

* remove digest auth (#5129)

* remove auth header and outbound message timeline (#5130)

* [INS-1840] Add Connected Status Label and Extras (#5131)

* add status related changes

* text label change

* WebSocket ipc typing proposal (#5125)

* make consistent with main bridge

* rename webSocket

* remove deviated mock

* use consistent arrow function defintions

* Update packages/insomnia/src/main/network/websocket.ts

* Set the environment for websocket responses (#5132)

* first pass (#5123)

* [INS-1843] Add more checks to WS smoke test (#5138)

* Fix failing websocket smoke test

* Add remaining websocket smoke server endopints to fixtures

* Add checks for basic-auth, bearer and redirect

* Rm assertion

* can select payload type (#5133)

* Implement nunjucks rendering for websocket urls, authentication, headers and urls (#5134)

* fix  websocket->webSocket (#5142)

* bug fix (#5144)

* INS-1844: Implement basic event view functionality. (#5146)

* INS-1844: Implement basic event view functionality

* CSS fix to align Preview button with tab buttons.

* Reverse the order of WebSocket events (#5148)

* Reverse the event log order in the view and subscribe to messages after the latest

* WebSocket response model (#5147)

* init websocket-response model

* add websocket response model

* remove unused timeline getter

* remove unused functionality from the ws-response model

* can select and save payload message and preview mode (#5143)

* [INS-1838] Add Empty State (#5140)

* add empty state

* use the empty state pane

Co-authored-by: gatzjames <jamesgatzos@gmail.com>

* Bug/stabilise-ws-fetch-method (#5152)

* just poll

* remove cts and eventlog channel

* Fix/nitpicks (#5156)

* remove unused names

* fix copypasta icon

* ensure all subcompnent drop state on request change

* move empty state to response pane

* add websockets to quick switch (#5159)

* [INS-1800] Remove value validity check in the WebSocket headers (#5154)

* remove valid value check in the headers

* add header name filtering

Co-authored-by: Dimitri Mitropoulos <dimitrimitropoulos@gmail.com>
Co-authored-by: Mark Kim <mark.kim@konghq.com>
Co-authored-by: Filipe Freire <livrofubia@gmail.com>
Co-authored-by: Jack Kavanagh <jackkav@gmail.com>
Co-authored-by: John Chadwick <86682572+johnwchadwick@users.noreply.github.com>
Co-authored-by: David Marby <david@dmarby.se>
This commit is contained in:
James Gatz
2022-09-09 16:53:28 +02:00
committed by GitHub
parent 1338f211dc
commit 0373bb578b
93 changed files with 3238 additions and 442 deletions

View File

@@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 7C14 3.13401 10.866 0 7 0C3.13401 0 0 3.13401 0 7C0 10.866 3.13401 14 7 14C10.866 14 14 10.866 14 7ZM1.5 7C1.5 3.96243 3.96243 1.5 7 1.5C10.0376 1.5 12.5 3.96243 12.5 7C12.5 10.0376 10.0376 12.5 7 12.5C3.96243 12.5 1.5 10.0376 1.5 7ZM10.5304 5.53039L9.46973 4.46973L6.00006 7.9394L4.53039 6.46973L3.46973 7.53039L6.00006 10.0607L10.5304 5.53039Z" fill="#12C76C"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 0C10.866 0 14 3.13401 14 7C14 10.866 10.866 14 7 14C3.13401 14 0 10.866 0 7C0 3.13401 3.13401 0 7 0ZM7 1.5C3.96243 1.5 1.5 3.96243 1.5 7C1.5 10.0376 3.96243 12.5 7 12.5C10.0376 12.5 12.5 10.0376 12.5 7C12.5 3.96243 10.0376 1.5 7 1.5ZM5.93934 7L4.46973 5.53039L5.53039 4.46973L7 5.93934L8.46961 4.46973L9.53027 5.53039L8.06066 7L9.53039 8.46973L8.46973 9.53039L7 8.06066L5.53027 9.53039L4.46961 8.46973L5.93934 7Z" fill="#EFA63B"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

View File

@@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<path d="M6.25006 2V9.12868L4.53039 7.40901L3.46973 8.46967L7.00006 12L10.5304 8.46967L9.46973 7.40901L7.75006 9.12868V2H6.25006Z" fill="#36CB84"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<path d="M7.82397 12L7.82397 4.87132L9.54104 6.59099L10.6001 5.53033L7.0751 2L3.5501 5.53033L4.60916 6.59099L6.32623 4.87132L6.32623 12L7.82397 12Z" fill="#2978E5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 7C14 3.134 10.866 0 7 0C3.13403 0 0 3.134 0 7C0 10.866 3.13403 14 7 14C10.866 14 14 10.866 14 7ZM1.5 7C1.5 3.96243 3.96246 1.5 7 1.5C10.0375 1.5 12.5 3.96243 12.5 7C12.5 10.0376 10.0375 12.5 7 12.5C3.96246 12.5 1.5 10.0376 1.5 7ZM7 5C7.55231 5 8 4.55228 8 4C8 3.44772 7.55231 3 7 3C6.44769 3 6 3.44772 6 4C6 4.55228 6.44769 5 7 5ZM8 11V7V6H5V7H6V11H8Z" fill="white" fill-opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@@ -8,10 +8,12 @@ import { SvgIcnBrackets } from './assets/svgr/IcnBrackets';
import { SvgIcnBug } from './assets/svgr/IcnBug';
import { SvgIcnBurgerMenu } from './assets/svgr/IcnBurgerMenu';
import { SvgIcnCheckmark } from './assets/svgr/IcnCheckmark';
import { SvgIcnCheckmarkCircle } from './assets/svgr/IcnCheckmarkCircle';
import { SvgIcnChevronDown } from './assets/svgr/IcnChevronDown';
import { SvgIcnChevronUp } from './assets/svgr/IcnChevronUp';
import { SvgIcnClock } from './assets/svgr/IcnClock';
import { SvgIcnCookie } from './assets/svgr/IcnCookie';
import { SvgIcnDisconnected } from './assets/svgr/IcnDisconnected';
import { SvgIcnDraftingCompass } from './assets/svgr/IcnDraftingCompass';
import { SvgIcnDragGrip } from './assets/svgr/IcnDragGrip';
import { SvgIcnElevator } from './assets/svgr/IcnElevator';
@@ -43,10 +45,13 @@ import { SvgIcnPlus } from './assets/svgr/IcnPlus';
import { SvgIcnProhibited } from './assets/svgr/IcnProhibited';
import { SvgIcnQuestion } from './assets/svgr/IcnQuestion';
import { SvgIcnQuestionFill } from './assets/svgr/IcnQuestionFill';
import { SvgIcnReceive } from './assets/svgr/IcnReceive';
import { SvgIcnSearch } from './assets/svgr/IcnSearch';
import { SvgIcnSecCert } from './assets/svgr/IcnSecCert';
import { SvgIcnSent } from './assets/svgr/IcnSent';
import { SvgIcnSuccess } from './assets/svgr/IcnSuccess';
import { SvgIcnSync } from './assets/svgr/IcnSync';
import { SvgIcnSystemEvent } from './assets/svgr/IcnSystemEvent';
import { SvgIcnTrashcan } from './assets/svgr/IcnTrashcan';
import { SvgIcnTriangle } from './assets/svgr/IcnTriangle';
import { SvgIcnUser } from './assets/svgr/IcnUser';
@@ -122,7 +127,11 @@ export const IconEnum = {
warning: 'warning',
warningCircle: 'warning-circle',
x: 'x',
disconnected: 'disconnected',
checkmarkCircle: 'checkmark-circle',
receive: 'receive',
sent: 'sent',
systemEvent: 'system-event',
/** Blank icon */
empty: 'empty',
} as const;
@@ -224,6 +233,11 @@ export class SvgIcon extends Component<SvgIconProps> {
[IconEnum.warningCircle]: [ThemeEnum.default, SvgIcnWarningCircle],
[IconEnum.warning]: [ThemeEnum.notice, SvgIcnWarning],
[IconEnum.x]: [ThemeEnum.default, SvgIcnX],
[IconEnum.disconnected]: [ThemeEnum.default, SvgIcnDisconnected],
[IconEnum.receive]: [ThemeEnum.default, SvgIcnReceive],
[IconEnum.sent]: [ThemeEnum.default, SvgIcnSent],
[IconEnum.checkmarkCircle]: [ThemeEnum.default, SvgIcnCheckmarkCircle],
[IconEnum.systemEvent]: [ThemeEnum.default, SvgIcnSystemEvent],
};
render() {

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQaumV23vAEqn4
KAJ+zQ7nKlLWVwhqMjOS7pz3HohLf4HER6SQ6bhXj2dlsW2TEDZQcOxw9jyTs4Wu
ckpqWy//UQ4LawQ3FGpst2kFAtFY6D93odh0mjPCLzQlXpcGKs6LOzlWAXWxrV6k
HRCsPT6ytnSOZNMlDNQKB+dZJ+vFz2c5LYyPXUkVenYsYH38E/ZFIz5GVRJlLhcH
IKRK3cJoGSQfljlFFDDiwvHc5/hszKIA0zHjk4MmnSw4Dm1P7fiFsI8zZEkNACBG
O/Lf6lcWX8r+1LfrGsmRfku4GrP5LXWD/ToLgmo2CAP3TBM9ftCggKhTbJfZxMWb
Q3DAui1RAgMBAAECggEBAKKM4AW7Gzdg1yPuwJN5B0iQH++ADdYVtVfBtraeH5sS
pXkaj2VehCH2fKQ5z8ZFfLcce6xWwERKXdcC2Ls+x56P7y5ElxMGX0LPgZ8g5Xo8
GVQK7LF0my22dys2LP/oXxMEa+GCXfLnzsqcyKYtVjs4RovQY0WgTbhNFcjZc+/g
PL3DclvJHJ5n9Bb+ufxJO3K7i4sxD8gcGfgXTVSghM+VCkslw0BJcqpNyGgEjI64
FTf7Qu1E0rKRGT0EKm70RffoZpLNdpMR/9GMHE5CAMSGie27AK2X8OKCgmqCh3Kp
wKqy3hRNnk9LNfhaan2LuprKlv11bJwAFAQIJSKtzOkCgYEA6n3NI5U4h//w0SeG
eaoOqXDyWmjSj4w1MbPV4sPvs1hG0iXn+w7+dfocXGawoPyu5zB9MhGf3mRB/8dp
6gb+xvYYSoVysh4VboBMJbWBlkWH4/q5KBnwuEVZDPy5fDpK449SlVmiqfpOH7hZ
wzGV5Wdwzofxp4nVIhCT/ryFYP8CgYEA44jcXh9rOHeRySo4ZofBP0QqAdT48ffl
D+VZ2q3/DNj67dYPfo8hhnEgsgVPz02ODHh8aMYVQp4OYrXpdh3/qqnWR7BB5ch1
lVFSOjytp17TSEm//vXz+WzSyrtlInGjqGazUGMpz+9j672be4y+wdxeyNDq04lr
UQXIAsP0Ia8CgYAvhA5th29NH6/MshWt0afm7dwuNc91BxRAXhCZQtrvnJO9QbEg
TomBnozgrG5eMNXAQzMbUjby+Z3mFqJ/qas25edGMoRrU21EVvsXKRB5Qt2mdMfQ
OHFu5Z6F4zAy3B0Qv5ocaW1sxCvQgaquwv183tkdAK8XI/bsUC+tDsZ4QQKBgQDL
kYTnSODa0k9CVV3Ejaydd9TFcs+PXKQ5ho7PkWBhFDfcVeni5xetesUvwITZCaAP
FDTqYF5hDZv9QJexL8Gv5OdrmAw9Ew3wG6Ofqu4KklIhmKoH5/DxtSUacHJZUKaF
Ye0H/NBJ0vnozeivrwpz0z+SFyghPg8fnDaIEtz2zQKBgHfG8/eXi1ZOiv1nLEuZ
SvJY8IU89aTqhgVsVk0g6h1jEBhZI4mTyvJe4OyC2JjxX4YD9gxu2AXSf2w7yU94
jRZdqJpLoXtcbIy9nAdYa0AyyyPKzwcSUepO2pqE3QCOSwxlplEqrWVzD9FyHFGJ
0RyshruFkeq15L6ud/6fY8Wz
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIESDCCArCgAwIBAgIRAKkiT43PW7dM415hMVMjLlIwDQYJKoZIhvcNAQELBQAw
ezEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSgwJgYDVQQLDB9kYXZp
ZEBETWFyYnktV29yayAoRGF2aWQgTWFyYnkpMS8wLQYDVQQDDCZta2NlcnQgZGF2
aWRARE1hcmJ5LVdvcmsgKERhdmlkIE1hcmJ5KTAeFw0yMjA4MjUxMzQ1NTBaFw0y
NDExMjUxNDQ1NTBaMFMxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0
aWZpY2F0ZTEoMCYGA1UECwwfZGF2aWRARE1hcmJ5LVdvcmsgKERhdmlkIE1hcmJ5
KTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANBq6ZXbe8ASqfgoAn7N
DucqUtZXCGoyM5LunPceiEt/gcRHpJDpuFePZ2WxbZMQNlBw7HD2PJOzha5ySmpb
L/9RDgtrBDcUamy3aQUC0VjoP3eh2HSaM8IvNCVelwYqzos7OVYBdbGtXqQdEKw9
PrK2dI5k0yUM1AoH51kn68XPZzktjI9dSRV6dixgffwT9kUjPkZVEmUuFwcgpErd
wmgZJB+WOUUUMOLC8dzn+GzMogDTMeOTgyadLDgObU/t+IWwjzNkSQ0AIEY78t/q
VxZfyv7Ut+sayZF+S7gas/ktdYP9OguCajYIA/dMEz1+0KCAqFNsl9nExZtDcMC6
LVECAwEAAaNvMG0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMC
BggrBgEFBQcDATAfBgNVHSMEGDAWgBQgZSNC3VSTkAucIY9/41pfQ64sozAbBgNV
HREEFDASghBjbGllbnQubG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBgQB4JLAo
j24DvacnTOxPu7gtpLFfB7ykgeAhzdWla7Maqp62lnasYUL9+nX8+aNOxYbd3uq/
55uSBBy2jnxce2B0oqkRhB0uOPZNwJsJXIZEp+anhz9E9jrgZhdMCDuhrwn5amPc
UmisabO5rPrFxmhKVhkoWHFPKVVOqqlnUt9UdFQsdIQoKYuX1BNro0QDOQ/p4Z7y
tCd6DsyhgV3iTDN3GkBv4fbYxxjD5tyxYKjCAwmcebOwEtAbHXFYpyVg0bNqEq0S
YofcZHkxFtBXV1Ayb1yhNYqNTu3VHTQkkE+XO2vJGBhbueKWQADLZ2fQ+8qkTqL8
AQ3YX4UQKcWcqwRUWpmEmr+XNfqYq8UrbO/liuPh0uQh7WkxHGlLMPe76jeK1FJP
P8T+Ivh7x7MCj2G/KlwIb7OgpjCuKeR28aSz3+WDInF4lydUwHDZveByVidBaD32
NIZeX3K8nj7tGpchbDHLiCtdSwTEXFKBPm5EcvAO92alis7mozgGrG3dK1M=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDTqFvEgk21fa9Z
e1OhPQ5BcUDIBLSY3F+1nNv5U0HKzUKEcoU+d0TER2/iNYPs2yLU2mK1UMgQsJfB
j/2ER0kVBSwJ4uk02HqtXtv/E9/RkwYycUsHZFX6wk8HVEKqfIxSgApGBoT15//l
DYFkJwtMvwib5szzmyVswHrYC15HY67o6PkNPyMfn/erOnu4akf0CA1lIg/2haeu
EmTx3mPs677f9xUt6MFHRAAZS6HZKu6THcW6VtOUybj9D+slDwQBJ2aS5Lq79VJd
cBXxCbq2UkXK/bIHkylQ2gtK2Gq8CvbnxmrGU9pXrIFRT7xLQTaUFxXeUMbGO98k
5Wu62FP9AgMBAAECggEBAMc+PCyvMPHBP9jvNFmbPRkzwHTJoSwof1xaLeT1kACV
2qENoQqdgbl3OgZqtCa6Xn6amcLvKXY0lpbash7ccBp+hOdFmJxrkIg1vMjQ76e8
TGAdsDBkLl/gnD5c/mi41+stpv4mUvGdlJENdplN++AiELuZt6M2kDNgugM4KGbv
45cwuu0DDldCSlp9ujFvn4QipEzf4wcNqvMyaNCzGw/7FyRQncZTe9J3E1RV+Tow
UWqXSR3LhRUNpn65khtPBgkE+ffAFf6d2+wwm5Cex+VOTlMQnkgtk6IgAweBkXY4
KBmzTupm1TqfQX16zUzDaq3zO2YUhwAMwxo2Dm2rIE0CgYEA29vOXaD8PXepMwDw
2tJS/c3WKNgTeZ1B8vQxlduwfuJeMHVXF20y2kO2rhEsrPTIUX7mM8Bj59vP9rcX
2P4GTBjvsvZj/yYrFV2EyXu+LjPbPMJCi8P/J8w8f0Pwy3xME/awdi477rm43cjn
cBlZNAl0KK3npDd5L5Dfs7fAuu8CgYEA9nNvdQ2EqH1wychTeg3Mj2CsnFI/lPAm
CmUL/Dmt47NXMkG+i1DfyaKTqF7M2+cWphzsk3ppwLfU2am2Uk9DgQl7BkFoLCXY
7xD0gfIAQKrbLKo9wHvjdn3j8rbnDb/A4qCA3ea5kItWviQbUdgl3amnp9Ts2tE8
3H09kLmZz9MCgYBbXonw10p8sRIcJDP0fJwI5lYuOz48uGID+f/xa49569GBCgLb
tYIAu0tcI78RUdk+JSK+NyJN5UgUHBtJDqjHT0Wudj8wdkhJZMgeg9KRmPNv2LuX
IikT/QjXSwDzUAC9+zNyqdw2ZfCyGyAzshUkTxl7Hmq6EGPIpMiTA7aQ+QKBgQDr
2Kp4DRi/mVPfdnsUWbJCH5Tv//Hi2TK+Tdb7aENVnaG7cZkkf5+5uYCu5xIK+4n8
K7/mnoYnrITgS/4zpLEIAoeeA+fqH8oLdmFXHb1KJXebtctkseqK0YzcEFbrHG89
MbZBJPS+M+ouCiWu3DfYeev8u9Jy0Tv6EUxifIuKiwKBgQDJPmxCPM3HS3fvO+7w
DlMtR52IKjohaKmMnt5EtNUR1Xs9vZCa51SvNWA6YJ+zqoGQVCXUoy77O43cewK3
crv3Hz4wWENG/bVfJQlkpFS4t+RJzNqEzeXSYQxFVfQlnhoeEAj/cZWuOJpkhmC2
2JkrwTxrofjxbbfD/m56XUusQg==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIENzCCAp+gAwIBAgIRAPKpJUU8eOZ/zg0WbmKsy8IwDQYJKoZIhvcNAQELBQAw
ezEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSgwJgYDVQQLDB9kYXZp
ZEBETWFyYnktV29yayAoRGF2aWQgTWFyYnkpMS8wLQYDVQQDDCZta2NlcnQgZGF2
aWRARE1hcmJ5LVdvcmsgKERhdmlkIE1hcmJ5KTAeFw0yMjA4MjUxMzQ1MzdaFw0y
NDExMjUxNDQ1MzdaMFMxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0
aWZpY2F0ZTEoMCYGA1UECwwfZGF2aWRARE1hcmJ5LVdvcmsgKERhdmlkIE1hcmJ5
KTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANOoW8SCTbV9r1l7U6E9
DkFxQMgEtJjcX7Wc2/lTQcrNQoRyhT53RMRHb+I1g+zbItTaYrVQyBCwl8GP/YRH
SRUFLAni6TTYeq1e2/8T39GTBjJxSwdkVfrCTwdUQqp8jFKACkYGhPXn/+UNgWQn
C0y/CJvmzPObJWzAetgLXkdjrujo+Q0/Ix+f96s6e7hqR/QIDWUiD/aFp64SZPHe
Y+zrvt/3FS3owUdEABlLodkq7pMdxbpW05TJuP0P6yUPBAEnZpLkurv1Ul1wFfEJ
urZSRcr9sgeTKVDaC0rYarwK9ufGasZT2lesgVFPvEtBNpQXFd5QxsY73yTla7rY
U/0CAwEAAaNeMFwwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMB
MB8GA1UdIwQYMBaAFCBlI0LdVJOQC5whj3/jWl9DriyjMBQGA1UdEQQNMAuCCWxv
Y2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAYEAORoaX+VVxk2HyJZveHJgQGcKgOwm
0jm3VQ2d4fFlfiAsLbpkZAeTApu1A4ylan8q6clmraP7GyNf7iZ3OMPkS4vVONMs
sJmFkrKsES4mF3CHKK7+8/bRtB0Y0yTGDxWOiQpZ3Ok6S84WTPylFyTZi4GVRN8P
+KhOaGleNLaf4m2ts0RbMh33va3UKn1b5VgxhdQKGUHcQYTwhmXVW3GDNv2W0Adk
hCaHIDIDAKoCZVm6HYcf/GW4EhJaujnxSWPR8os25+DePeHhrgKvlHMyyevSLcqP
U+ZN3Lk8nNIgCOuhPke7J71IJAX4lklljfeL0sQHDd/5mwLlEtWvaRNGfovnfBfL
ut5Pq0QU2WkOMwdYPJ62fuCm9GX58tXb9MfezFst0EEpf2S0Tn/7TvIXq7DOfzZu
htaGf6HrfkV159ULFuAEog+2HwvPGW3wLVQN5n6QgV/uv0W40Wm1SVpEGG4cK1r4
emap+9mwik3/bLP0xeuTG8S6X7MZde3By1xo
-----END CERTIFICATE-----

View File

@@ -0,0 +1,40 @@
-----BEGIN PRIVATE KEY-----
MIIG/AIBADANBgkqhkiG9w0BAQEFAASCBuYwggbiAgEAAoIBgQCix4kz1jyTIvAh
tzLqg40XpbNwbZg0VDPXkwPtb6YfOA+k/pPuPRPrhYkVoybNqWtQLBtol77SUuvy
ytJZtKY1gRTM4Hzw+ZoReEPV1ekJr5DBqMXkVQHIlpokFQsgX/dW/vzfQ0jjcfMt
HE+FYLJKWZ3Xby+fVkjSl9V75uMHD3f5klJmXKPTMGlBKf+aCwTlBvXdn9oXAqf+
jQQi1b8xvfGdXMBYEhOoW4HbdPhwMHuzMlF5y8s6Cd9WMQoEVrKiAqT7C4p79GgL
wAT6lrbCmvw85t7NKc/2+o8JvF85AcM9XkouydhMPsWl00vfApnN6EEJYX8+P5Vm
comCC5fVygxzZOj/ElpIfx4OXy39lnJLGov4etpYtV5RDKrmvSjid6wXimYHORC7
sWkJU7PqnHxooylgzbOsqlCp7GVZLzAeuSMnwJfsmSJ7GVMNq5UkQv9KygxwFmMu
OgR087Ui340y+RuqMZzJdBow+2aCYPw+zZg+nq7ZAECZbJW/OmkCAwEAAQKCAYBb
VByUquS9oOKd6A13KmvlEqEEuVimM4AKuX+Anh3Ucj3E0tjo1/fvMrLhIvLIfP+q
sbSHEGyN0Nx1EnrGveZrKosjD+jJwyFAH/vfY+8l8g0Dus+c9lzT0DuXdv8RIQbD
FrmGAlhI1EwdyT6MlN4zfOhkUQulGGIvVeT/aWGDOpiTvBbjO6LnAMhtOUUhhoEu
hqM3v1I7is/6r9/cM5TcMbf4FGwOfcXttbm8CXrCZ1FgDyFLdp4FaDiYQVdfa8xU
ZoSv5qF/C5TU2hlkgb28SrlgkY6WnsQ9lUHTg/vdEr7xqnE48yCwsdhiNd5dn9Wv
Lm6gB+CkSbXFOZDFFqEbFljb7OseRNQEwoAkuRgqqni+z3Fw29T+N/YM7OEMJOGR
63haPProD2OcyYs7lCvCL446qtCT+IdWG75q0DFjaCGCett/D9LEnuR7BgFAMJ9Q
ZQHISFt0HevR8neWbse6gZk9sRRU8d0WEpT0YsBS6cHG5upWigTOuvEUEF82CqEC
gcEAwmk08kbMEzSdKm2kQxuNCrVJCBkzDYCP95c2f2NsvGFjdbcw0zwl9/i4lLD2
4XNzxcdbNjuAGoflkeR6DCYKpd6Ed0gJfFj8+Bpt3dgt7apnxWvba3Af07RhWkSx
EweusKBJ4olNDacD2ayLlnXwU8KMmbngsEiz7476t4pt3SEX6CsIKQsd3QnhsFPK
PpmPUyPFl6FOH07pWca5cx4r1/392sdXI1MLpwFycyaIuBg5zJD7YCQmU51eZeKD
oJFVAoHBANZZAHumSApHUcplUDK7YW37vqESr6Hhn7YipLADppPcrjohI+OCRGW3
mNNgrnG+MKKTu8w5eOEdXMPH2L89m6gXZLZhhupV++3/+xECAI0coJ5FhFbBeS+Z
UbOhmba0cuurSH2f6UbOnBuk3r/xIzERmoUQHEPmy3nbvUae+2ZvDsSjFvUbFZRK
Dhuc/6NnhZ8v1gGRAtkeB8atWNd8y4Wa3heG/BzjtG7v16J8bXbj9MAgAOC7XqxK
8uriu4HUxQKBwDAi4pQ2iWMb/Oo7eZeQI80J9ApISwbF1V/Flh3WnV7Lclf8Qt+a
ngAXGoTeiFJsRrcq1F/KPb7T9Ti5bKrDZJGLVhs+v/KFCiXYTWnHlB+ruMP+H7cr
bQX7PLugFIQUu+FJ3uFzg5ukxeRIv8tCan4ixrNtfb/IUJ05NsTpRqihAA1hUkTv
VrabMsF5DbOQTBeW3N7ddr1zyX2MIfDqAIsbfZaFEwNRFaqFRjRSzzld7jnDkCpO
6Rp89Zmei17ffQKBwFKNBHqal7Qds4pXaoOfVu6cvdYa9DlMQ85JmVOQlF7t5svM
Z53/VYg3JUyDN6vmq6RxcSo+GCfavxdHqFo+x+v81nTHKsalRtlqdK8gLkYqeFF8
RHOFH78NNUIRQrny1S+eT9TR+W2jtMuQu5kAraUAOpp0ke40vLi5wDOqlvfkXbOt
e/H59F2gB77qwCmWfQfJzInd51LnFeeWa1jSXy+dbVtySTZ3G8594HZbpWzcbi5w
JOZoQxXn55Y+rChcYQKBwBZDTwcLMCW3WuHhNDAOOf7xx+OnvChcTDMhbMtNeGNv
47UtHZ2FQF1UpeyGmTlKwbiBPPk8fU90yR+vf9wKAQTFLV/gtFsDC8cnMr75t4Jq
GJzKfO88oNqY8Tvxr3s94wW1YTLqFqGBPSdKVy7+vcZfsR6e+x9I2Tj7r8b6wzLE
qwNBQNufwef4eoDzyE69dIaaDHHTV1q+Z1bOF4XGAcm/efK3jhNlPK98tSCme+jr
TmvRvDdQuagS/w4gM0gSrA==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,28 @@
-----BEGIN CERTIFICATE-----
MIIExTCCAy2gAwIBAgIQb/tURt+KKadPV6mtgAlcPDANBgkqhkiG9w0BAQsFADB7
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExKDAmBgNVBAsMH2Rhdmlk
QERNYXJieS1Xb3JrIChEYXZpZCBNYXJieSkxLzAtBgNVBAMMJm1rY2VydCBkYXZp
ZEBETWFyYnktV29yayAoRGF2aWQgTWFyYnkpMB4XDTIyMDgyNTEzNDM1N1oXDTMy
MDgyNTEzNDM1N1owezEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSgw
JgYDVQQLDB9kYXZpZEBETWFyYnktV29yayAoRGF2aWQgTWFyYnkpMS8wLQYDVQQD
DCZta2NlcnQgZGF2aWRARE1hcmJ5LVdvcmsgKERhdmlkIE1hcmJ5KTCCAaIwDQYJ
KoZIhvcNAQEBBQADggGPADCCAYoCggGBAKLHiTPWPJMi8CG3MuqDjRels3BtmDRU
M9eTA+1vph84D6T+k+49E+uFiRWjJs2pa1AsG2iXvtJS6/LK0lm0pjWBFMzgfPD5
mhF4Q9XV6QmvkMGoxeRVAciWmiQVCyBf91b+/N9DSONx8y0cT4VgskpZnddvL59W
SNKX1Xvm4wcPd/mSUmZco9MwaUEp/5oLBOUG9d2f2hcCp/6NBCLVvzG98Z1cwFgS
E6hbgdt0+HAwe7MyUXnLyzoJ31YxCgRWsqICpPsLinv0aAvABPqWtsKa/Dzm3s0p
z/b6jwm8XzkBwz1eSi7J2Ew+xaXTS98Cmc3oQQlhfz4/lWZyiYILl9XKDHNk6P8S
Wkh/Hg5fLf2Wcksai/h62li1XlEMqua9KOJ3rBeKZgc5ELuxaQlTs+qcfGijKWDN
s6yqUKnsZVkvMB65IyfAl+yZInsZUw2rlSRC/0rKDHAWYy46BHTztSLfjTL5G6ox
nMl0GjD7ZoJg/D7NmD6ertkAQJlslb86aQIDAQABo0UwQzAOBgNVHQ8BAf8EBAMC
AgQwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUIGUjQt1Uk5ALnCGPf+Na
X0OuLKMwDQYJKoZIhvcNAQELBQADggGBACVC0yCFLKD5BqBbvHOTPh4pDGl+dmxR
Ta/GjZvXisbxKA/YlwZ+deENLW8veYoDgr7QEOb8QvXiqdd/no+rAU18KnTlwOsD
ITITqF8tStTJKPBubws4S9fhQIeDs+7MS7mW7lfbIz4ho77KcweSbndak9NW7cBl
AwoCPcK/uCH0F2EHDNAQnxf1OVdRREPWD4/Di9EhYr6T5BaRVuD/pB4eYUb9shqD
6vLKVYb/vpYIHc5vEDYT4cmaFW8rXJc+gHZzs+SkTpjvUKZrz9JRrdAji+djPQlD
ZRry4t9De7nVdCZDGjbwzwuEtJxoveldDKcZ/BtRb7dVbU7SB6WqKjUzqlnCoLdE
eZBU2d6+r/ojnPCxHgzVRzABYF4vakIYg4VDVFYEVqdnBo8n5PJLX2gAHbaasOQ4
hPYthHs8RVk2O5+A/mBVeE09yCwhOAxvqi91ttS4uuN2ullmr07232Wzgc64GSJf
g1WKYRpirTEbH49z3lXRIwGaq2J1xArHsA==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,87 @@
_type: export
__export_format: 4
__export_date: 2022-08-31T10:40:21.266Z
__export_source: insomnia.desktop.app:v2022.5.0
resources:
- _id: ws-req_0ba3ad1a7a81483a8f0c0caab4e8998c
parentId: wrk_db59cf2b74764e6a80c0dcbcf3d67130
modified: 1661942223227
created: 1661942202873
name: localhost:4010
url: ws://localhost:4010
metaSortKey: -1661942202873
headers: []
authentication: {}
_type: websocket_request
- _id: wrk_db59cf2b74764e6a80c0dcbcf3d67130
parentId: null
modified: 1661942194367
created: 1661942194367
name: WebSockets
description: ""
scope: collection
_type: workspace
- _id: ws-req_c522379d686b44179e9626fc6c8ed88a
parentId: wrk_db59cf2b74764e6a80c0dcbcf3d67130
modified: 1662451274029
created: 1662451239058
name: basic-auth
url: ws://localhost:4010/basic-auth
metaSortKey: -1661942202823
headers: []
authentication:
type: basic
useISO88591: false
disabled: false
username: user
password: password
_type: websocket_request
- _id: ws-req_2157c597bcbb4614b64c7c99c6f7a982
parentId: wrk_db59cf2b74764e6a80c0dcbcf3d67130
modified: 1662451369688
created: 1662451318293
name: bearer
url: ws://localhost:4010/bearer
metaSortKey: -1661942202723
headers: []
authentication:
type: bearer
token: insomnia-cool-token-!!!1112113243111
disabled: false
_type: websocket_request
- _id: ws-req_6b9c944a7f034fcb8b0a92e5442538d7
parentId: wrk_db59cf2b74764e6a80c0dcbcf3d67130
modified: 1662451456879
created: 1662451430343
name: redirect
url: ws://localhost:4010/redirect
metaSortKey: -1661942202623
headers: []
authentication: {}
_type: websocket_request
- _id: env_78d7375877d288dfb527a256e6d7e92dce4ff968
parentId: wrk_db59cf2b74764e6a80c0dcbcf3d67130
modified: 1661942194375
created: 1661942194375
name: Base Environment
data: {}
dataPropertyOrder: null
color: null
isPrivate: false
metaSortKey: 1661942194375
_type: environment
- _id: jar_78d7375877d288dfb527a256e6d7e92dce4ff968
parentId: wrk_db59cf2b74764e6a80c0dcbcf3d67130
modified: 1661942194378
created: 1661942194378
name: Default Jar
cookies: []
_type: cookie_jar
- _id: spc_2d11197686aa40ec8f5f072727af4a7a
parentId: wrk_db59cf2b74764e6a80c0dcbcf3d67130
modified: 1661942194369
created: 1661942194369
fileName: ws example
contents: ""
contentType: yaml
_type: api_spec

View File

@@ -22,6 +22,7 @@
"@types/oidc-provider": "^7.8.1",
"@types/ramda": "^0.27.45",
"@types/uuid": "^8.3.4",
"@types/ws": "^8.5.3",
"concurrently": "^7.0.0",
"cross-env": "^7.0.3",
"execa": "^5.0.0",
@@ -38,6 +39,7 @@
"ramda-adjunct": "^2.34.0",
"ts-node": "^9.1.1",
"uuid": "^8.3.2",
"ws": "^8.8.1",
"xvfb-maybe": "^0.2.1"
}
},
@@ -1453,6 +1455,15 @@
"integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
"dev": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz",
@@ -5644,6 +5655,27 @@
"node": "^12.13.0 || ^14.15.0 || >=16"
}
},
"node_modules/ws": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.8.1.tgz",
"integrity": "sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==",
"dev": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xvfb-maybe": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/xvfb-maybe/-/xvfb-maybe-0.2.1.tgz",
@@ -6957,6 +6989,15 @@
"integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
"dev": true
},
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/yargs": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz",
@@ -10192,6 +10233,13 @@
"signal-exit": "^3.0.7"
}
},
"ws": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.8.1.tgz",
"integrity": "sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==",
"dev": true,
"requires": {}
},
"xvfb-maybe": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/xvfb-maybe/-/xvfb-maybe-0.2.1.tgz",

View File

@@ -41,6 +41,7 @@
"@types/oidc-provider": "^7.8.1",
"@types/ramda": "^0.27.45",
"@types/uuid": "^8.3.4",
"@types/ws": "^8.5.3",
"concurrently": "^7.0.0",
"cross-env": "^7.0.3",
"execa": "^5.0.0",
@@ -57,6 +58,7 @@
"ramda-adjunct": "^2.34.0",
"ts-node": "^9.1.1",
"uuid": "^8.3.2",
"ws": "^8.8.1",
"xvfb-maybe": "^0.2.1"
}
}

View File

@@ -1,5 +1,8 @@
import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { readFileSync } from 'fs';
import { createServer } from 'https';
import { join } from 'path';
import { basicAuthRouter } from './basic-auth';
import githubApi from './github-api';
@@ -7,9 +10,11 @@ import gitlabApi from './gitlab-api';
import { root, schema } from './graphql';
import { startGRPCServer } from './grpc';
import { oauthRoutes } from './oauth';
import { startWebSocketServer } from './websocket';
const app = express();
const port = 4010;
const httpsPort = 4011;
const grpcPort = 50051;
app.get('/pets/:id', (req, res) => {
@@ -55,7 +60,23 @@ app.use('/graphql', graphqlHTTP({
}));
startGRPCServer(grpcPort).then(() => {
app.listen(port, () => {
const server = app.listen(port, () => {
console.log(`Listening at http://localhost:${port}`);
console.log(`Listening at ws://localhost:${port}`);
});
const httpsServer = createServer({
cert: readFileSync(join(__dirname, '../fixtures/certificates/localhost.pem')),
ca: readFileSync(join(__dirname, '../fixtures/certificates/rootCA.pem')),
key: readFileSync(join(__dirname, '../fixtures/certificates/localhost-key.pem')),
// Only allow connections using valid client certificates
requestCert: true,
rejectUnauthorized: true,
}, app);
httpsServer.listen(httpsPort, () => {
console.log(`Listening at https://localhost:${httpsPort}`);
console.log(`Listening at wss://localhost:${httpsPort}`);
});
startWebSocketServer(server, httpsServer);
});

View File

@@ -0,0 +1,83 @@
import { IncomingMessage, Server } from 'http';
import { Socket } from 'net';
import { WebSocket, WebSocketServer } from 'ws';
/**
* Starts an echo WebSocket server that receives messages from a client and echoes them back.
*/
export function startWebSocketServer(server: Server, httpsServer: Server) {
const wsServer = new WebSocketServer({ noServer: true });
const wssServer = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
upgrade(wsServer, request, socket, head);
});
httpsServer.on('upgrade', (request, socket, head) => {
upgrade(wssServer, request, socket, head);
});
wsServer.on('connection', handleConnection);
wssServer.on('connection', handleConnection);
}
const handleConnection = (ws: WebSocket, req: IncomingMessage) => {
console.log('WebSocket connection was opened');
console.log('Upgrade headers:', req.headers);
ws.on('message', (message, isBinary) => {
if (isBinary) {
ws.send(message);
return;
}
if (message.toString() === 'close') {
ws.close(1003, 'Invalid message type');
}
ws.send(message.toString());
});
ws.on('close', () => {
console.log('WebSocket connection was closed');
});
};
const redirectOnSuccess = (socket: Socket) => {
socket.end(`HTTP/1.1 302 Found
Location: ws://localhost:4010
`);
return;
};
const return401withBody = (socket: Socket) => {
socket.end(`HTTP/1.1 401 Unauthorized
<!doctype html>
<html>
<body>
<div>
<h1>401 Unauthorized</h1>
</div>
</body>
</html>`);
return;
};
const upgrade = (wss: WebSocketServer, request: IncomingMessage, socket: Socket, head: Buffer) => {
if (request.url === '/redirect') {
return redirectOnSuccess(socket);
}
if (request.url === '/bearer') {
if (request.headers.authorization !== 'Bearer insomnia-cool-token-!!!1112113243111') {
return401withBody(socket);
return;
}
return redirectOnSuccess(socket);
}
if (request.url === '/basic-auth') {
// login with user:password
if (request.headers.authorization !== 'Basic dXNlcjpwYXNzd29yZA==') {
return401withBody(socket);
return;
}
return redirectOnSuccess(socket);
}
wss.handleUpgrade(request, socket, head, ws => {
wss.emit('connection', ws, request);
});
};

View File

@@ -0,0 +1,52 @@
import { expect } from '@playwright/test';
import { loadFixture } from '../playwright/paths';
import { test } from '../playwright/test';
test('can make websocket connection', async ({ app, page }) => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
const statusTag = page.locator('[data-testid="response-status-tag"]:visible');
const responseBody = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible', {
has: page.locator('.CodeMirror-activeline'),
});
await page.click('[data-testid="project"]');
await page.click('text=Create');
const text = await loadFixture('websockets.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.click('button:has-text("Clipboard")');
await page.click('text=CollectionWebSocketsjust now');
await page.click('button:has-text("localhost:4010")');
await page.click('text=Connect');
await expect(statusTag).toContainText('101 Switching Protocols');
await page.click('[data-testid="response-pane"] >> [role="tab"]:has-text("Timeline")');
await expect(responseBody).toContainText('WebSocket connection established');
await page.click('text=Disconnect');
await expect(responseBody).toContainText('Closing connection with code 1005');
// Can connect with Basic Auth
await page.click('button:has-text("basic-auth")');
await page.click('text=Connect');
await expect(statusTag).toContainText('101 Switching Protocols');
await page.click('[data-testid="response-pane"] >> [role="tab"]:has-text("Timeline")');
await expect(responseBody).toContainText('> authorization: Basic dXNlcjpwYXNzd29yZA==');
// Can connect with Bearer Auth
await page.click('button:has-text("bearer")');
await page.click('text=Connect');
await expect(statusTag).toContainText('101 Switching Protocols');
await page.click('[data-testid="response-pane"] >> [role="tab"]:has-text("Timeline")');
await expect(responseBody).toContainText('> authorization: Bearer insomnia-cool-token-!!!1112113243111');
// Can handle redirects
await page.click('button:has-text("redirect")');
await page.click('text=Connect');
await expect(statusTag).toContainText('101 Switching Protocols');
await page.click('[data-testid="response-pane"] >> [role="tab"]:has-text("Timeline")');
await expect(responseBody).toContainText('WebSocket connection established');
});

View File

@@ -98,6 +98,7 @@
"@types/tough-cookie": "^2.3.7",
"@types/uuid": "^8.3.4",
"@types/vkbeautify": "^0.99.2",
"@types/ws": "^8.5.3",
"@types/yaml": "^1.9.7",
"@vitejs/plugin-react": "^1.2.0",
"buffer": "^6.0.3",
@@ -140,6 +141,7 @@
"react-sortable-hoc": "^2.0.0",
"react-tabs": "^3.2.3",
"react-use": "^17.2.4",
"react-virtual": "2.10.4",
"redux": "^4.1.2",
"redux-mock-store": "^1.5.4",
"redux-thunk": "^2.4.1",
@@ -151,7 +153,8 @@
"typescript": "^4.5.5",
"vite": "^2.8.6",
"vite-plugin-commonjs-externals": "^0.1.1",
"vkbeautify": "^0.99.1"
"vkbeautify": "^0.99.1",
"ws": "^8.8.1"
}
},
"node_modules/@ampproject/remapping": {
@@ -3162,6 +3165,12 @@
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA="
},
"node_modules/@reach/observe-rect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==",
"dev": true
},
"node_modules/@repeaterjs/repeater": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.4.tgz",
@@ -17604,6 +17613,21 @@
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"dev": true
},
"node_modules/react-virtual": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz",
"integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/tannerlinsley"
],
"dependencies": {
"@reach/observe-rect": "^1.1.0"
},
"peerDependencies": {
"react": "^16.6.3 || ^17.0.0"
}
},
"node_modules/read-config-file": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.2.0.tgz",
@@ -20379,9 +20403,9 @@
}
},
"node_modules/ws": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz",
"integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==",
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.8.1.tgz",
"integrity": "sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==",
"dev": true,
"engines": {
"node": ">=10.0.0"
@@ -22979,6 +23003,12 @@
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA="
},
"@reach/observe-rect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==",
"dev": true
},
"@repeaterjs/repeater": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.4.tgz",
@@ -34248,6 +34278,15 @@
}
}
},
"react-virtual": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz",
"integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==",
"dev": true,
"requires": {
"@reach/observe-rect": "^1.1.0"
}
},
"read-config-file": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.2.0.tgz",
@@ -36425,9 +36464,9 @@
}
},
"ws": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz",
"integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==",
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.8.1.tgz",
"integrity": "sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==",
"dev": true,
"requires": {}
},

View File

@@ -153,6 +153,7 @@
"@types/tough-cookie": "^2.3.7",
"@types/uuid": "^8.3.4",
"@types/vkbeautify": "^0.99.2",
"@types/ws": "^8.5.3",
"@types/yaml": "^1.9.7",
"@vitejs/plugin-react": "^1.2.0",
"buffer": "^6.0.3",
@@ -196,6 +197,7 @@
"react-sortable-hoc": "^2.0.0",
"react-tabs": "^3.2.3",
"react-use": "^17.2.4",
"react-virtual": "2.10.4",
"redux": "^4.1.2",
"redux-mock-store": "^1.5.4",
"redux-thunk": "^2.4.1",
@@ -207,7 +209,8 @@
"typescript": "^4.5.5",
"vite": "^2.8.6",
"vite-plugin-commonjs-externals": "^0.1.1",
"vkbeautify": "^0.99.1"
"vkbeautify": "^0.99.1",
"ws": "^8.8.1"
},
"dev": {
"dev-server-port": 3334

View File

@@ -12,6 +12,7 @@ export class MockCodeEditor extends PureComponent<any> {
render() {
const { id, onChange, placeholder, defaultValue } = this.props;
return <textarea
data-testid="CodeEditor"
ref={this.ref}
id={id}
onChange={event => onChange(event.currentTarget.value)}

View File

@@ -25,7 +25,20 @@ export const isDevelopment = () => getAppEnvironment() === 'development';
export const getSegmentWriteKey = () => appConfig.segmentWriteKeys[(isDevelopment() || env.PLAYWRIGHT) ? 'development' : 'production'];
export const getSentryDsn = () => appConfig.sentryDsn;
export const getAppBuildDate = () => new Date(process.env.BUILD_DATE ?? '').toLocaleDateString();
export type AuthType =
| 'none'
| 'oauth2'
| 'oauth1'
| 'basic'
| 'digest'
| 'bearer'
| 'ntlm'
| 'hawk'
| 'iam'
| 'netrc'
| 'asap'
| 'sha256'
| 'sha1';
export const getBrowserUserAgent = () => encodeURIComponent(
String(window.navigator.userAgent)
.replace(new RegExp(`${getAppId()}\\/\\d+\\.\\d+\\.\\d+ `), '')
@@ -548,6 +561,8 @@ export const WORKSPACE_ID_KEY = '__WORKSPACE_ID__';
export const BASE_ENVIRONMENT_ID_KEY = '__BASE_ENVIRONMENT_ID__';
export const EXPORT_TYPE_REQUEST = 'request';
export const EXPORT_TYPE_GRPC_REQUEST = 'grpc_request';
export const EXPORT_TYPE_WEBSOCKET_REQUEST = 'websocket_request';
export const EXPORT_TYPE_WEBSOCKET_PAYLOAD = 'websocket_payload';
export const EXPORT_TYPE_REQUEST_GROUP = 'request_group';
export const EXPORT_TYPE_UNIT_TEST_SUITE = 'unit_test_suite';
export const EXPORT_TYPE_UNIT_TEST = 'unit_test';

View File

@@ -260,7 +260,6 @@ export const database = {
console.log(`[db] Dropped ${changes.length} changes.`);
return;
}
// Notify local listeners too
for (const fn of changeListeners) {
await fn(changes);
@@ -685,9 +684,9 @@ function getDBFilePath(modelType: string) {
let bufferingChanges = false;
let bufferChangesId = 1;
export type ChangeBufferEvent = [
export type ChangeBufferEvent<T extends BaseModel = BaseModel> = [
event: string,
doc: BaseModel,
doc: T,
fromSync: boolean
];

View File

@@ -14,6 +14,8 @@ import { isRequest } from '../models/request';
import { isRequestGroup } from '../models/request-group';
import { isUnitTest } from '../models/unit-test';
import { isUnitTestSuite } from '../models/unit-test-suite';
import { isWebSocketPayload } from '../models/websocket-payload';
import { isWebSocketRequest } from '../models/websocket-request';
import { isWorkspace, Workspace } from '../models/workspace';
import { resetKeys } from '../sync/ignore-keys';
import { SegmentEvent, trackSegmentEvent } from './analytics';
@@ -28,6 +30,8 @@ import {
EXPORT_TYPE_REQUEST_GROUP,
EXPORT_TYPE_UNIT_TEST,
EXPORT_TYPE_UNIT_TEST_SUITE,
EXPORT_TYPE_WEBSOCKET_PAYLOAD,
EXPORT_TYPE_WEBSOCKET_REQUEST,
EXPORT_TYPE_WORKSPACE,
getAppVersion,
} from './constants';
@@ -174,7 +178,8 @@ export async function exportRequestsData(
isUnitTestSuite(d) ||
isUnitTest(d) ||
isProtoFile(d) ||
isProtoDirectory(d)
isProtoDirectory(d) ||
isWebSocketPayload(d)
);
});
docs.push(...descendants);
@@ -188,6 +193,8 @@ export async function exportRequestsData(
isUnitTestSuite(d) ||
isUnitTest(d) ||
isRequest(d) ||
isWebSocketPayload(d) ||
isWebSocketRequest(d) ||
isGrpcRequest(d) ||
isRequestGroup(d) ||
isProtoFile(d) ||
@@ -231,6 +238,12 @@ export async function exportRequestsData(
} else if (isGrpcRequest(d)) {
// @ts-expect-error -- TSCONVERSION maybe this needs to be added to the upstream type?
d._type = EXPORT_TYPE_GRPC_REQUEST;
} else if (isWebSocketPayload(d)) {
// @ts-expect-error -- TSCONVERSION maybe this needs to be added to the upstream type?
d._type = EXPORT_TYPE_WEBSOCKET_PAYLOAD;
} else if (isWebSocketRequest(d)) {
// @ts-expect-error -- TSCONVERSION maybe this needs to be added to the upstream type?
d._type = EXPORT_TYPE_WEBSOCKET_REQUEST;
} else if (isProtoFile(d)) {
// @ts-expect-error -- TSCONVERSION maybe this needs to be added to the upstream type?
d._type = EXPORT_TYPE_PROTO_FILE;

View File

@@ -9,6 +9,7 @@ import type { GrpcRequest, GrpcRequestBody } from '../models/grpc-request';
import { isProject, Project } from '../models/project';
import type { Request } from '../models/request';
import { isRequestGroup, RequestGroup } from '../models/request-group';
import { WebSocketRequest } from '../models/websocket-request';
import { isWorkspace, Workspace } from '../models/workspace';
import * as templating from '../templating';
import * as templatingUtils from '../templating/utils';
@@ -289,7 +290,7 @@ export async function render<T>(
return next<T>(newObj, name, true);
}
interface RenderRequest<T extends Request | GrpcRequest> {
interface RenderRequest<T extends Request | GrpcRequest | WebSocketRequest> {
request: T;
}
@@ -299,7 +300,7 @@ interface BaseRenderContextOptions {
extraInfo?: ExtraRenderInfo;
}
interface RenderContextOptions extends BaseRenderContextOptions, Partial<RenderRequest<Request | GrpcRequest>> {
interface RenderContextOptions extends BaseRenderContextOptions, Partial<RenderRequest<Request | GrpcRequest | WebSocketRequest>> {
ancestors?: RenderContextAncestor[];
}
export async function getRenderContext(
@@ -557,11 +558,12 @@ function _getOrderedEnvironmentKeys(finalRenderContext: Record<string, any>): st
});
}
type RenderContextAncestor = Request | GrpcRequest | RequestGroup | Workspace | Project;
export async function getRenderContextAncestors(base?: Request | GrpcRequest | Workspace): Promise<RenderContextAncestor[]> {
type RenderContextAncestor = Request | GrpcRequest | WebSocketRequest | RequestGroup | Workspace | Project;
export async function getRenderContextAncestors(base?: Request | GrpcRequest | WebSocketRequest | Workspace): Promise<RenderContextAncestor[]> {
return await db.withAncestors<RenderContextAncestor>(base || null, [
models.request.type,
models.grpcRequest.type,
models.webSocketRequest.type,
models.requestGroup.type,
models.workspace.type,
models.project.type,

View File

@@ -13,6 +13,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 { registerWebSocketHandlers } from './main/network/websocket';
import { initializeSentry, sentryWatchAnalyticsEnabled } from './main/sentry';
import { checkIfRestartNeeded } from './main/squirrel-startup';
import * as updates from './main/updates';
@@ -70,6 +71,7 @@ app.on('ready', async () => {
registerElectronHandlers();
registerMainHandlers();
registergRPCHandlers();
registerWebSocketHandlers();
disableSpellcheckerDownload();

View File

@@ -4,6 +4,7 @@ import { writeFile } from 'fs/promises';
import { authorizeUserInWindow } from '../../network/o-auth-2/misc';
import installPlugin from '../install-plugin';
import { cancelCurlRequest, curlRequest } from '../network/libcurl-promise';
import { WebSocketBridgeAPI } from '../network/websocket';
export interface MainBridgeAPI {
restart: () => void;
@@ -13,7 +14,8 @@ export interface MainBridgeAPI {
writeFile: (options: { path: string; content: string }) => Promise<string>;
cancelCurlRequest: typeof cancelCurlRequest;
curlRequest: typeof curlRequest;
on: (channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void) => Function;
on: (channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void) => () => void;
webSocket: WebSocketBridgeAPI;
}
export function registerMainHandlers() {
ipcMain.handle('authorizeUserInWindow', (_, options: Parameters<typeof authorizeUserInWindow>[0]) => {

View File

@@ -0,0 +1,429 @@
import electron, { ipcMain } from 'electron';
import fs from 'fs';
import { IncomingMessage } from 'http';
import { setDefaultProtocol } from 'insomnia-url';
import mkdirp from 'mkdirp';
import path from 'path';
import { KeyObject, PxfObject } from 'tls';
import { v4 as uuidV4 } from 'uuid';
import {
CloseEvent,
ErrorEvent,
Event,
MessageEvent,
WebSocket,
} from 'ws';
import { AUTH_BASIC, AUTH_BEARER } from '../../common/constants';
import { generateId } from '../../common/misc';
import { webSocketRequest } from '../../models';
import * as models from '../../models';
import { Environment } from '../../models/environment';
import { RequestAuthentication, RequestHeader } from '../../models/request';
import { BaseWebSocketRequest } from '../../models/websocket-request';
import type { WebSocketResponse } from '../../models/websocket-response';
import { getBasicAuthHeader } from '../../network/basic-auth/get-header';
import { getBearerAuthHeader } from '../../network/bearer-auth/get-header';
import { urlMatchesCertHost } from '../../network/url-matches-cert-host';
export interface WebSocketConnection extends WebSocket {
_id: string;
requestId: string;
}
export type WebSocketOpenEvent = Omit<Event, 'target'> & {
_id: string;
requestId: string;
type: 'open';
timestamp: number;
};
export type WebSocketMessageEvent = Omit<MessageEvent, 'target'> & {
_id: string;
requestId: string;
direction: 'OUTGOING' | 'INCOMING';
type: 'message';
timestamp: number;
};
export type WebSocketErrorEvent = Omit<ErrorEvent, 'target'> & {
_id: string;
requestId: string;
type: 'error';
timestamp: number;
};
export type WebSocketCloseEvent = Omit<CloseEvent, 'target'> & {
_id: string;
requestId: string;
type: 'close';
timestamp: number;
};
export type WebSocketEvent =
| WebSocketOpenEvent
| WebSocketMessageEvent
| WebSocketErrorEvent
| WebSocketCloseEvent;
export type WebSocketEventLog = WebSocketEvent[];
const WebSocketConnections = new Map<string, WebSocket>();
const eventLogFileStreams = new Map<string, fs.WriteStream>();
const timelineFileStreams = new Map<string, fs.WriteStream>();
const parseResponseAndBuildTimeline = (url: string, incomingMessage: IncomingMessage, clientRequestHeaders: string) => {
const statusMessage = incomingMessage.statusMessage || '';
const statusCode = incomingMessage.statusCode || 0;
const httpVersion = incomingMessage.httpVersion;
const responseHeaders = Object.entries(incomingMessage.headers).map(([name, value]) => ({ name, value: value?.toString() || '' }));
const headersIn = responseHeaders.map(({ name, value }) => `${name}: ${value}`).join('\n');
const timeline = [
{ value: `Preparing request to ${url}`, name: 'Text', timestamp: Date.now() },
{ value: `Current time is ${new Date().toISOString()}`, name: 'Text', timestamp: Date.now() },
{ value: 'Using HTTP 1.1', name: 'Text', timestamp: Date.now() },
{ value: clientRequestHeaders, name: 'HeaderOut', timestamp: Date.now() },
{ value: `HTTP/${httpVersion} ${statusCode} ${statusMessage}`, name: 'HeaderIn', timestamp: Date.now() },
{ value: headersIn, name: 'HeaderIn', timestamp: Date.now() },
];
return { timeline, responseHeaders, statusCode, statusMessage, httpVersion };
};
const createWebSocketConnection = async (
event: Electron.IpcMainInvokeEvent,
options: {
requestId: string;
workspaceId: string;
url: string;
headers: RequestHeader[];
authentication: RequestAuthentication;
}
): Promise<void> => {
const existingConnection = WebSocketConnections.get(options.requestId);
if (existingConnection) {
console.warn('Connection still open to ' + existingConnection.url);
return;
}
const request = await webSocketRequest.getById(options.requestId);
const responseId = generateId('res');
if (!request) {
return;
}
const responsesDir = path.join(process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData'), 'responses');
mkdirp.sync(responsesDir);
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;
try {
const readyStateChannel = `webSocket.${request._id}.readyState`;
const reduceArrayToLowerCaseKeyedDictionary = (acc: { [key: string]: string }, { name, value }: BaseWebSocketRequest['headers'][0]) =>
({ ...acc, [name.toLowerCase() || '']: value || '' });
const headers = options.headers;
if (!options.authentication.disabled) {
if (options.authentication.type === AUTH_BASIC) {
const { username, password, useISO88591 } = options.authentication;
const encoding = useISO88591 ? 'latin1' : 'utf8';
headers.push(getBasicAuthHeader(username, password, encoding));
}
if (options.authentication.type === AUTH_BEARER) {
const { token, prefix } = options.authentication;
headers.push(getBearerAuthHeader(token, prefix));
}
}
const lowerCasedEnabledHeaders = headers
.filter(({ name, disabled }) => Boolean(name) && !disabled)
.reduce(reduceArrayToLowerCaseKeyedDictionary, {});
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, 'wss:'), options.url));
const pemCertificates: string[] = [];
const pemCertificateKeys: KeyObject[] = [];
const pfxCertificates: PxfObject[] = [];
filteredClientCertificates.forEach(clientCertificate => {
const { passphrase, cert, key, pfx } = clientCertificate;
if (cert) {
timelineFileStreams.get(options.requestId)?.write(JSON.stringify({ value: `Adding SSL PEM certificate: ${cert}`, name: 'Text', timestamp: Date.now() }) + '\n');
pemCertificates.push(fs.readFileSync(cert, 'utf-8'));
}
if (key) {
timelineFileStreams.get(options.requestId)?.write(JSON.stringify({ value: `Adding SSL KEY certificate: ${key}`, name: 'Text', timestamp: Date.now() }) + '\n');
pemCertificateKeys.push({ pem: fs.readFileSync(key, 'utf-8'), passphrase: passphrase ?? undefined });
}
if (pfx) {
timelineFileStreams.get(options.requestId)?.write(JSON.stringify({ value: `Adding SSL P12 certificate: ${pfx}`, name: 'Text', timestamp: Date.now() }) + '\n');
pfxCertificates.push({ buf: fs.readFileSync(pfx, 'utf-8'), passphrase: passphrase ?? undefined });
}
});
const ws = new WebSocket(options.url, {
headers: lowerCasedEnabledHeaders,
cert: pemCertificates,
key: pemCertificateKeys,
pfx: pfxCertificates,
rejectUnauthorized: settings.validateSSL,
followRedirects: true,
});
WebSocketConnections.set(options.requestId, ws);
ws.on('upgrade', async incomingMessage => {
// @ts-expect-error -- private property
const internalRequestHeader = ws._req._header;
const { timeline, responseHeaders, statusCode, statusMessage, httpVersion } = parseResponseAndBuildTimeline(options.url, incomingMessage, internalRequestHeader);
timeline.map(t => timelineFileStreams.get(options.requestId)?.write(JSON.stringify(t) + '\n'));
const responsePatch: Partial<WebSocketResponse> = {
_id: responseId,
parentId: request._id,
environmentId: responseEnvironmentId,
headers: responseHeaders,
url: options.url,
statusCode,
statusMessage,
httpVersion,
elapsedTime: performance.now() - start,
timelinePath,
eventLogPath: responseBodyPath,
};
const settings = await models.settings.getOrCreate();
models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: null });
});
ws.on('unexpected-response', async (clientRequest, incomingMessage) => {
incomingMessage.on('data', chunk => {
timelineFileStreams.get(options.requestId)?.write(JSON.stringify({ value: chunk.toString(), name: 'DataOut', timestamp: Date.now() }) + '\n');
});
// @ts-expect-error -- private property
const internalRequestHeader = clientRequest._header;
const { timeline, responseHeaders, statusCode, statusMessage, httpVersion } = parseResponseAndBuildTimeline(options.url, incomingMessage, internalRequestHeader);
timeline.map(t => timelineFileStreams.get(options.requestId)?.write(JSON.stringify(t) + '\n'));
const responsePatch: Partial<WebSocketResponse> = {
_id: responseId,
parentId: request._id,
environmentId: responseEnvironmentId,
headers: responseHeaders,
url: options.url,
statusCode,
statusMessage,
httpVersion,
elapsedTime: performance.now() - start,
timelinePath,
eventLogPath: responseBodyPath,
};
const settings = await models.settings.getOrCreate();
models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: null });
deleteRequestMaps(request._id, `Unexpected response ${incomingMessage.statusCode}`);
});
ws.addEventListener('open', () => {
const openEvent: WebSocketOpenEvent = {
_id: uuidV4(),
requestId: options.requestId,
type: 'open',
timestamp: Date.now(),
};
eventLogFileStreams.get(options.requestId)?.write(JSON.stringify(openEvent) + '\n');
timelineFileStreams.get(options.requestId)?.write(JSON.stringify({ value: 'WebSocket connection established', name: 'Text', timestamp: Date.now() }) + '\n');
event.sender.send(readyStateChannel, ws.readyState);
});
ws.addEventListener('message', ({ data }: MessageEvent) => {
const messageEvent: WebSocketMessageEvent = {
_id: uuidV4(),
requestId: options.requestId,
data,
type: 'message',
direction: 'INCOMING',
timestamp: Date.now(),
};
eventLogFileStreams.get(options.requestId)?.write(JSON.stringify(messageEvent) + '\n');
});
ws.addEventListener('close', ({ code, reason, wasClean }) => {
const closeEvent: WebSocketCloseEvent = {
_id: uuidV4(),
requestId: options.requestId,
code,
reason,
type: 'close',
wasClean,
timestamp: Date.now(),
};
const message = `Closing connection with code ${code}`;
deleteRequestMaps(request._id, message, closeEvent);
event.sender.send(readyStateChannel, ws.readyState);
});
ws.addEventListener('error', async ({ error, message }: ErrorEvent) => {
console.error(error);
const errorEvent: WebSocketErrorEvent = {
_id: uuidV4(),
requestId: options.requestId,
message,
type: 'error',
error,
timestamp: Date.now(),
};
deleteRequestMaps(request._id, message, errorEvent);
event.sender.send(readyStateChannel, ws.readyState);
createErrorResponse(responseId, request._id, responseEnvironmentId, timelinePath, message || 'Something went wrong');
});
} 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,
};
models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: null });
};
const deleteRequestMaps = async (requestId: string, message: string, event?: WebSocketCloseEvent | WebSocketErrorEvent) => {
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);
WebSocketConnections.delete(requestId);
};
const getWebSocketReadyState = async (
options: { requestId: string }
): Promise<WebSocketConnection['readyState']> => {
return WebSocketConnections.get(options.requestId)?.readyState ?? 0;
};
const sendWebSocketEvent = async (
options: { message: string; requestId: string }
): Promise<void> => {
const ws = WebSocketConnections.get(options.requestId);
if (!ws) {
console.warn('No websocket found for requestId: ' + options.requestId);
return;
}
ws.send(options.message, error => {
// @TODO: We might want to set a status in the WebSocketMessageEvent
// and update it here based on the error. e.g. status = 'sending' | 'sent' | 'error'
if (error) {
console.error(error);
} else {
console.log('Message sent');
}
});
const lastMessage: WebSocketMessageEvent = {
_id: uuidV4(),
requestId: options.requestId,
data: options.message,
direction: 'OUTGOING',
type: 'message',
timestamp: Date.now(),
};
eventLogFileStreams.get(options.requestId)?.write(JSON.stringify(lastMessage) + '\n');
const response = await models.webSocketResponse.getLatestByParentId(options.requestId);
if (!response) {
console.error('something went wrong');
return;
}
};
const closeWebSocketConnection = async (
options: { requestId: string }
): Promise<void> => {
const ws = WebSocketConnections.get(options.requestId);
if (!ws) {
return;
}
ws.close();
};
const closeAllWebSocketConnections = (): void => {
WebSocketConnections.forEach(ws => ws.close());
};
const findMany = async (
options: { responseId: string }
): Promise<WebSocketEvent[]> => {
const response = await models.webSocketResponse.getById(options.responseId);
if (!response || !response.eventLogPath) {
return [];
}
const body = await fs.promises.readFile(response.eventLogPath);
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 WebSocketBridgeAPI {
create: (options: {
requestId: string;
workspaceId: string;
url: string;
headers: RequestHeader[];
authentication: RequestAuthentication;
}) => void;
close: typeof closeWebSocketConnection;
closeAll: typeof closeAllWebSocketConnections;
readyState: {
getCurrent: typeof getWebSocketReadyState;
};
event: {
findMany: typeof findMany;
send: (options: { requestId: string; message: string }) => void;
};
}
export const registerWebSocketHandlers = () => {
ipcMain.handle('webSocket.create', createWebSocketConnection);
ipcMain.handle('webSocket.event.send', (_, options: Parameters<typeof sendWebSocketEvent>[0]) => sendWebSocketEvent(options));
ipcMain.handle('webSocket.close', (_, options: Parameters<typeof closeWebSocketConnection>[0]) => closeWebSocketConnection(options));
ipcMain.handle('webSocket.closeAll', closeAllWebSocketConnections);
ipcMain.handle('webSocket.readyState', (_, options: Parameters<typeof getWebSocketReadyState>[0]) => getWebSocketReadyState(options));
ipcMain.handle('webSocket.event.findMany', (_, options: Parameters<typeof findMany>[0]) => findMany(options));
};
electron.app.on('window-all-closed', () => {
WebSocketConnections.forEach(ws => {
ws.close();
});
});

View File

@@ -1,31 +1,54 @@
import { GrpcRequest, isGrpcRequest, isGrpcRequestId } from '../grpc-request';
import * as models from '../index';
import { Request } from '../request';
import { isWebSocketRequest, isWebSocketRequestId, WebSocketRequest } from '../websocket-request';
export function getById(requestId: string): Promise<Request | GrpcRequest | null> {
return isGrpcRequestId(requestId)
? models.grpcRequest.getById(requestId)
: models.request.getById(requestId);
export function getById(requestId: string): Promise<Request | GrpcRequest | WebSocketRequest | null> {
if (isGrpcRequestId(requestId)) {
return models.grpcRequest.getById(requestId);
}
if (isWebSocketRequestId(requestId)) {
return models.webSocketRequest.getById(requestId);
}
return models.request.getById(requestId);
}
export function remove(request: Request | GrpcRequest) {
return isGrpcRequest(request)
? models.grpcRequest.remove(request)
: models.request.remove(request);
export function remove(request: Request | GrpcRequest | WebSocketRequest) {
if (isGrpcRequest(request)) {
return models.grpcRequest.remove(request);
}
if (isWebSocketRequest(request)) {
return models.webSocketRequest.remove(request);
}
return models.request.remove(request);
}
export function update<T extends object>(request: T, patch: Partial<T> = {}): Promise<T> {
// @ts-expect-error -- TSCONVERSION
return isGrpcRequest(request)
? models.grpcRequest.update(request, patch)
if (isGrpcRequest(request)) {
// @ts-expect-error -- TSCONVERSION
return models.grpcRequest.update(request, patch);
}
// @ts-expect-error -- TSCONVERSION
: models.request.update(request, patch);
if (isWebSocketRequest(request)) {
// @ts-expect-error -- TSCONVERSION
return models.webSocketRequest.update(request, patch);
}
// @ts-expect-error -- TSCONVERSION
return models.request.update(request, patch);
}
export function duplicate<T extends object>(request: T, patch: Partial<T> = {}): Promise<T> {
// @ts-expect-error -- TSCONVERSION
return isGrpcRequest(request)
? models.grpcRequest.duplicate(request, patch)
if (isGrpcRequest(request)) {
// @ts-expect-error -- TSCONVERSION
return models.grpcRequest.duplicate(request, patch);
}
// @ts-expect-error -- TSCONVERSION
: models.request.duplicate(request, patch);
if (isWebSocketRequest(request)) {
// @ts-expect-error -- TSCONVERSION
return models.webSocketRequest.duplicate(request, patch);
}
// @ts-expect-error -- TSCONVERSION
return models.request.duplicate(request, patch);
}

View File

@@ -9,6 +9,8 @@ import {
EXPORT_TYPE_REQUEST_GROUP,
EXPORT_TYPE_UNIT_TEST,
EXPORT_TYPE_UNIT_TEST_SUITE,
EXPORT_TYPE_WEBSOCKET_PAYLOAD,
EXPORT_TYPE_WEBSOCKET_REQUEST,
EXPORT_TYPE_WORKSPACE,
} from '../common/constants';
import { generateId, pluralize } from '../common/misc';
@@ -35,6 +37,9 @@ import * as _stats from './stats';
import * as _unitTest from './unit-test';
import * as _unitTestResult from './unit-test-result';
import * as _unitTestSuite from './unit-test-suite';
import * as _webSocketPayload from './websocket-payload';
import * as _webSocketRequest from './websocket-request';
import * as _webSocketResponse from './websocket-response';
import * as _workspace from './workspace';
import * as _workspaceMeta from './workspace-meta';
@@ -75,6 +80,9 @@ export const protoFile = _protoFile;
export const protoDirectory = _protoDirectory;
export const grpcRequest = _grpcRequest;
export const grpcRequestMeta = _grpcRequestMeta;
export const webSocketPayload = _webSocketPayload;
export const webSocketRequest = _webSocketRequest;
export const webSocketResponse = _webSocketResponse;
export const workspace = _workspace;
export const workspaceMeta = _workspaceMeta;
@@ -108,6 +116,9 @@ export function all() {
protoDirectory,
grpcRequest,
grpcRequestMeta,
webSocketPayload,
webSocketRequest,
webSocketResponse,
] as const;
}
@@ -207,6 +218,8 @@ export async function initModel<T extends BaseModel>(type: string, ...sources: R
export const MODELS_BY_EXPORT_TYPE: Record<string, any> = {
[EXPORT_TYPE_REQUEST]: request,
[EXPORT_TYPE_WEBSOCKET_PAYLOAD]: webSocketPayload,
[EXPORT_TYPE_WEBSOCKET_REQUEST]: webSocketRequest,
[EXPORT_TYPE_GRPC_REQUEST]: grpcRequest,
[EXPORT_TYPE_REQUEST_GROUP]: requestGroup,
[EXPORT_TYPE_UNIT_TEST_SUITE]: unitTestSuite,

View File

@@ -2,9 +2,11 @@ import deepEqual from 'deep-equal';
import { database as db } from '../common/database';
import { compressObject, decompressObject } from '../common/misc';
import * as requestOperations from '../models/helpers/request-operations';
import { GrpcRequest } from './grpc-request';
import type { BaseModel } from './index';
import * as models from './index';
import { isRequest, Request } from './request';
import { isWebSocketRequest, WebSocketRequest } from './websocket-request';
export const name = 'Request Version';
@@ -51,9 +53,9 @@ export function getById(id: string) {
return db.get<RequestVersion>(type, id);
}
export async function create(request: Request) {
if (!isRequest(request)) {
throw new Error(`New ${type} was not given a valid ${models.request.type} instance`);
export async function create(request: Request | WebSocketRequest | GrpcRequest) {
if (!isRequest(request) && !isWebSocketRequest(request)) {
throw new Error(`New ${type} was not given a valid ${request.type} instance`);
}
const parentId = request._id;
@@ -90,7 +92,7 @@ export async function restore(requestVersionId: string) {
}
const requestPatch = decompressObject(requestVersion.compressedRequest);
const originalRequest = await models.request.getById(requestPatch._id);
const originalRequest = await requestOperations.getById(requestPatch._id);
if (!originalRequest) {
return null;
@@ -101,20 +103,18 @@ export async function restore(requestVersionId: string) {
delete requestPatch[field];
}
return models.request.update(originalRequest, requestPatch);
return requestOperations.update(originalRequest, requestPatch);
}
function _diffRequests(rOld: Request | null, rNew: Request) {
function _diffRequests(rOld: Request | WebSocketRequest | null, rNew: Request | WebSocketRequest) {
if (!rOld) {
return true;
}
for (const key of Object.keys(rOld) as (keyof Request)[]) {
for (const key of Object.keys(rOld) as (keyof typeof rOld)[]) {
// Skip fields that aren't useful
if (FIELDS_TO_IGNORE.includes(key)) {
continue;
}
if (!deepEqual(rOld[key], rNew[key])) {
return true;
}

View File

@@ -4,6 +4,7 @@ import zlib from 'zlib';
import { database as db, Query } from '../common/database';
import type { ResponseTimelineEntry } from '../main/network/libcurl-promise';
import * as requestOperations from '../models/helpers/request-operations';
import type { BaseModel } from './index';
import * as models from './index';
@@ -61,19 +62,19 @@ export function init(): BaseResponse {
contentType: '',
url: '',
bytesRead: 0,
bytesContent: -1,
// -1 means that it was legacy and this property didn't exist yet
bytesContent: -1,
elapsedTime: 0,
headers: [],
timelinePath: '',
// Actual timelines are stored on the filesystem
bodyPath: '',
timelinePath: '',
// Actual bodies are stored on the filesystem
bodyCompression: '__NEEDS_MIGRATION__',
bodyPath: '',
// For legacy bodies
bodyCompression: '__NEEDS_MIGRATION__',
error: '',
requestVersionId: null,
// Things from the request
requestVersionId: null,
settingStoreCookies: null,
settingSendCookies: null,
// Responses sent before environment filtering will have a special value
@@ -163,7 +164,7 @@ export async function create(patch: Record<string, any> = {}, maxResponses = 20)
const { parentId } = patch;
// Create request version snapshot
const request = await models.request.getById(parentId);
const request = await requestOperations.getById(parentId);
const requestVersion = request ? await models.requestVersion.create(request) : null;
patch.requestVersionId = requestVersion ? requestVersion._id : null;
// Filter responses by environment if setting is enabled
@@ -224,7 +225,8 @@ export function getTimeline(response: Response, showBody?: boolean) {
try {
const rawBuffer = fs.readFileSync(timelinePath);
const timeline = JSON.parse(rawBuffer.toString()) as ResponseTimelineEntry[];
const timelineString = rawBuffer.toString();
const timeline = JSON.parse(timelineString) as ResponseTimelineEntry[];
const body: ResponseTimelineEntry[] = showBody ? [
{
name: 'DataOut',

View File

@@ -0,0 +1,70 @@
import { database } from '../common/database';
import type { BaseModel } from '.';
export const name = 'WebSocket Payload';
export const type = 'WebSocketPayload';
export const prefix = 'ws-payload';
export const canDuplicate = true;
// @TODO: enable this at some point
export const canSync = false;
export interface BaseWebSocketPayload {
value: string;
mode: string;
}
export type WebSocketPayload = BaseModel & BaseWebSocketPayload & { type: typeof type };
export const isWebSocketPayload = (model: Pick<BaseModel, 'type'>): model is WebSocketPayload => (
model.type === type
);
export const isWebSocketPayloadId = (id: string | null) => (
id?.startsWith(`${prefix}_`)
);
export const init = (): BaseWebSocketPayload => ({
value: '',
mode: 'application/json',
});
export const migrate = (doc: WebSocketPayload) => doc;
export const create = (patch: Partial<WebSocketPayload> = {}) => {
if (!patch.parentId) {
throw new Error(`New WebSocketPayload missing \`parentId\`: ${JSON.stringify(patch)}`);
}
return database.docCreate<WebSocketPayload>(type, patch);
};
export const remove = (obj: WebSocketPayload) => database.remove(obj);
export const update = (
obj: WebSocketPayload,
patch: Partial<WebSocketPayload> = {}
) => database.docUpdate(obj, patch);
export async function duplicate(request: WebSocketPayload, patch: Partial<WebSocketPayload> = {}) {
// Only set name and "(Copy)" if the patch does
// not define it and the request itself has a name.
// Otherwise leave it blank so the request URL can
// fill it in automatically.
if (!patch.name && request.name) {
patch.name = `${request.name} (Copy)`;
}
return database.duplicate<WebSocketPayload>(request, {
name,
...patch,
});
}
export const getById = (_id: string) => database.getWhere<WebSocketPayload>(type, { _id });
export const getByParentId = (parentId: string) => database.getWhere<WebSocketPayload>(type, { parentId });
export const all = () => database.all<WebSocketPayload>(type);

View File

@@ -0,0 +1,92 @@
import { database } from '../common/database';
import type { BaseModel } from '.';
import { RequestAuthentication, RequestHeader } from './request';
export const name = 'WebSocket Request';
export const type = 'WebSocketRequest';
export const prefix = 'ws-req';
export const canDuplicate = true;
// @TODO: enable this at some point
export const canSync = false;
export interface BaseWebSocketRequest {
name: string;
url: string;
metaSortKey: number;
headers: RequestHeader[];
authentication: RequestAuthentication;
}
export type WebSocketRequest = BaseModel & BaseWebSocketRequest & { type: typeof type };
export const isWebSocketRequest = (model: Pick<BaseModel, 'type'>): model is WebSocketRequest => (
model.type === type
);
export const isWebSocketRequestId = (id: string | null) => (
id?.startsWith(`${prefix}_`)
);
export const init = (): BaseWebSocketRequest => ({
name: 'New WebSocket Request',
url: '',
metaSortKey: -1 * Date.now(),
headers: [],
authentication: {},
});
export const migrate = (doc: WebSocketRequest) => doc;
export const create = (patch: Partial<WebSocketRequest> = {}) => {
if (!patch.parentId) {
throw new Error(`New WebSocketRequest missing \`parentId\`: ${JSON.stringify(patch)}`);
}
return database.docCreate<WebSocketRequest>(type, patch);
};
export const remove = (obj: WebSocketRequest) => database.remove(obj);
export const update = (
obj: WebSocketRequest,
patch: Partial<WebSocketRequest> = {}
) => database.docUpdate(obj, patch);
// This is duplicated (lol) from models/request.js
export async function duplicate(request: WebSocketRequest, patch: Partial<WebSocketRequest> = {}) {
// Only set name and "(Copy)" if the patch does
// not define it and the request itself has a name.
// Otherwise leave it blank so the request URL can
// fill it in automatically.
if (!patch.name && request.name) {
patch.name = `${request.name} (Copy)`;
}
// Get sort key of next request
const q = {
metaSortKey: {
$gt: request.metaSortKey,
},
};
// @ts-expect-error -- Database TSCONVERSION
const [nextRequest] = await db.find<WebSocketRequest>(type, q, {
metaSortKey: 1,
});
const nextSortKey = nextRequest ? nextRequest.metaSortKey : request.metaSortKey + 100;
// Calculate new sort key
const sortKeyIncrement = (nextSortKey - request.metaSortKey) / 2;
const metaSortKey = request.metaSortKey + sortKeyIncrement;
return database.duplicate<WebSocketRequest>(request, {
name,
metaSortKey,
...patch,
});
}
export const getById = (_id: string) => database.getWhere<WebSocketRequest>(type, { _id });
export const all = () => database.all<WebSocketRequest>(type);

View File

@@ -0,0 +1,151 @@
import fs from 'fs';
import { database as db } from '../common/database';
import * as requestOperations from './helpers/request-operations';
import type { BaseModel } from './index';
import * as models from './index';
import { ResponseHeader } from './response';
export const name = 'WebSocket Response';
export const type = 'WebSocketResponse';
export const prefix = 'ws-res';
export const canDuplicate = false;
export const canSync = false;
export interface BaseWebSocketResponse {
environmentId: string | null;
statusCode: number;
statusMessage: string;
httpVersion: string;
contentType: string;
url: string;
elapsedTime: number;
headers: ResponseHeader[];
// Event logs are stored on the filesystem
eventLogPath: string;
// Actual timelines are stored on the filesystem
timelinePath: string;
error: string;
requestVersionId: string | null;
settingStoreCookies: boolean | null;
settingSendCookies: boolean | null;
}
export type WebSocketResponse = BaseModel & BaseWebSocketResponse;
export const isWebSocketResponse = (model: Pick<BaseModel, 'type'>): model is WebSocketResponse => (
model.type === type
);
export function init(): BaseWebSocketResponse {
return {
statusCode: 0,
statusMessage: '',
httpVersion: '',
contentType: '',
url: '',
elapsedTime: 0,
headers: [],
timelinePath: '',
eventLogPath: '',
error: '',
requestVersionId: null,
settingStoreCookies: null,
settingSendCookies: null,
environmentId: null,
};
}
export async function migrate(doc: Response) {
return doc;
}
export function hookDatabaseInit(consoleLog: typeof console.log = console.log) {
consoleLog('[db] Init websocket-responses DB');
}
export function hookRemove(doc: WebSocketResponse, consoleLog: typeof console.log = console.log) {
fs.unlink(doc.eventLogPath, () => {
consoleLog(`[response] Delete body ${doc.eventLogPath}`);
});
fs.unlink(doc.timelinePath, () => {
consoleLog(`[response] Delete timeline ${doc.timelinePath}`);
});
}
export function getById(id: string) {
return db.get<WebSocketResponse>(type, id);
}
export async function all() {
return db.all<WebSocketResponse>(type);
}
export async function removeForRequest(parentId: string, environmentId?: string | null) {
const settings = await models.settings.getOrCreate();
const query: Record<string, any> = {
parentId,
};
// Only add if not undefined. null is not the same as undefined
// null: find responses sent from base environment
// undefined: find all responses
if (environmentId !== undefined && settings.filterResponsesByEnv) {
query.environmentId = environmentId;
}
// Also delete legacy responses here or else the user will be confused as to
// why some responses are still showing in the UI.
await db.removeWhere(type, query);
}
export function remove(response: WebSocketResponse) {
return db.remove(response);
}
export async function create(patch: Partial<WebSocketResponse> = {}, maxResponses = 20) {
if (!patch.parentId) {
throw new Error('New Response missing `parentId`');
}
const { parentId } = patch;
// Create request version snapshot
const request = await requestOperations.getById(parentId);
const requestVersion = request ? await models.requestVersion.create(request) : null;
patch.requestVersionId = requestVersion ? requestVersion._id : null;
// Filter responses by environment if setting is enabled
const query: Record<string, any> = {
parentId,
};
if (
(await models.settings.getOrCreate()).filterResponsesByEnv &&
patch.hasOwnProperty('environmentId')
) {
query.environmentId = patch.environmentId;
}
// Delete all other responses before creating the new one
const allResponses = await db.findMostRecentlyModified<WebSocketResponse>(type, query, Math.max(1, maxResponses));
const recentIds = allResponses.map(r => r._id);
// Remove all that were in the last query, except the first `maxResponses` IDs
await db.removeWhere(type, {
...query,
_id: {
$nin: recentIds,
},
});
// Actually create the new response
return db.docCreate(type, patch);
}
export function getLatestByParentId(parentId: string) {
return db.getMostRecentlyModified<WebSocketResponse>(type, {
parentId,
});
}

View File

@@ -451,7 +451,7 @@ async function _applyResponsePluginHooks(
}
function storeTimeline(timeline: ResponseTimelineEntry[]) {
export function storeTimeline(timeline: ResponseTimelineEntry[]): Promise<string> {
const timelineStr = JSON.stringify(timeline, null, '\t');
const timelineHash = uuidv4();
const responsesDir = pathJoin(getDataDirectory(), 'responses');

View File

@@ -1,5 +1,20 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { WebSocketBridgeAPI } from './main/network/websocket';
const webSocket: WebSocketBridgeAPI = {
create: options => ipcRenderer.invoke('webSocket.create', options),
close: options => ipcRenderer.invoke('webSocket.close', options),
closeAll: () => ipcRenderer.invoke('webSocket.closeAll'),
readyState: {
getCurrent: options => ipcRenderer.invoke('webSocket.readyState', options),
},
event: {
findMany: options => ipcRenderer.invoke('webSocket.event.findMany', options),
send: options => ipcRenderer.invoke('webSocket.event.send', options),
},
};
const main: Window['main'] = {
restart: () => ipcRenderer.send('restart'),
authorizeUserInWindow: options => ipcRenderer.invoke('authorizeUserInWindow', options),
@@ -12,6 +27,7 @@ const main: Window['main'] = {
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
webSocket,
};
const dialog: Window['dialog'] = {
showOpenDialog: options => ipcRenderer.invoke('showOpenDialog', options),

View File

@@ -385,6 +385,7 @@ export class OneLineEditor extends PureComponent<Props, State> {
getAutocompleteConstants={getAutocompleteConstants}
className={classnames('editor--single-line', className)}
defaultValue={defaultValue}
readOnly={this.props.readOnly}
/>
</Fragment>
);
@@ -402,6 +403,7 @@ export class OneLineEditor extends PureComponent<Props, State> {
}}
placeholder={placeholder}
defaultValue={defaultValue}
disabled={this.props.readOnly}
onBlur={this._handleInputBlur}
onChange={this._handleInputChange}
onMouseEnter={this._handleInputMouseEnter}

View File

@@ -2,21 +2,14 @@ import React, { FC, useCallback } from 'react';
import { useSelector } from 'react-redux';
import {
AUTH_ASAP,
AUTH_AWS_IAM,
AUTH_BASIC,
AUTH_BEARER,
AUTH_DIGEST,
AUTH_HAWK,
AUTH_NETRC,
AUTH_NONE,
AUTH_NTLM,
AUTH_OAUTH_1,
AUTH_OAUTH_2,
AuthType,
getAuthTypeName,
HAWK_ALGORITHM_SHA256,
} from '../../../common/constants';
import * as models from '../../../models';
import { update } from '../../../models/helpers/request-operations';
import { RequestAuthentication } from '../../../models/request';
import { SIGNATURE_METHOD_HMAC_SHA1 } from '../../../network/o-auth-1/constants';
import { GRANT_TYPE_AUTHORIZATION_CODE } from '../../../network/o-auth-2/constants';
import { selectActiveRequest } from '../../redux/selectors';
import { Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
@@ -25,11 +18,110 @@ import { DropdownItem } from '../base/dropdown/dropdown-item';
import { showModal } from '../modals';
import { AlertModal } from '../modals/alert-modal';
const defaultTypes: AuthType[] = [
'basic',
'digest',
'oauth1',
'oauth2',
'ntlm',
'iam',
'bearer',
'hawk',
'asap',
'netrc',
];
function makeNewAuth(type: string, oldAuth: RequestAuthentication = {}): RequestAuthentication {
switch (type) {
// No Auth
case 'none':
return {};
// HTTP Basic Authentication
case 'basic':
return {
type,
useISO88591: oldAuth.useISO88591 || false,
disabled: oldAuth.disabled || false,
username: oldAuth.username || '',
password: oldAuth.password || '',
};
case 'digest':
case 'ntlm':
return {
type,
disabled: oldAuth.disabled || false,
username: oldAuth.username || '',
password: oldAuth.password || '',
};
case 'oauth1':
return {
type,
disabled: false,
signatureMethod: SIGNATURE_METHOD_HMAC_SHA1,
consumerKey: '',
consumerSecret: '',
tokenKey: '',
tokenSecret: '',
privateKey: '',
version: '1.0',
nonce: '',
timestamp: '',
callback: '',
};
// OAuth 2.0
case 'oauth2':
return {
type,
grantType: GRANT_TYPE_AUTHORIZATION_CODE,
};
// Aws IAM
case 'iam':
return {
type,
disabled: oldAuth.disabled || false,
accessKeyId: oldAuth.accessKeyId || '',
secretAccessKey: oldAuth.secretAccessKey || '',
sessionToken: oldAuth.sessionToken || '',
};
// Hawk
case 'hawk':
return {
type,
algorithm: HAWK_ALGORITHM_SHA256,
};
// Atlassian ASAP
case 'asap':
return {
type,
issuer: '',
subject: '',
audience: '',
additionalClaims: '',
keyId: '',
privateKey: '',
};
// Types needing no defaults
case 'netrc':
default:
return {
type,
};
}
}
const AuthItem: FC<{
type: string;
type: AuthType;
nameOverride?: string;
isCurrent: (type: string) => boolean;
onClick: (type: string) => void;
isCurrent: (type: AuthType) => boolean;
onClick: (type: AuthType) => void;
}> = ({ type, nameOverride, isCurrent, onClick }) => (
<DropdownItem onClick={onClick} value={type}>
{<i className={`fa fa-${isCurrent(type) ? 'check' : 'empty'}`} />}{' '}
@@ -38,16 +130,15 @@ const AuthItem: FC<{
);
AuthItem.displayName = DropdownItem.name;
export const AuthDropdown: FC = () => {
interface Props {
authTypes?: AuthType[];
disabled?: boolean;
}
export const AuthDropdown: FC<Props> = ({ authTypes = defaultTypes, disabled = false }) => {
const activeRequest = useSelector(selectActiveRequest);
const onClick = useCallback(async (type: string) => {
if (!activeRequest) {
return;
}
if (!('authentication' in activeRequest)) {
// gRPC Requests don't have `authentication`
const onClick = useCallback(async (type: AuthType) => {
if (!activeRequest || !('authentication' in activeRequest)) {
return;
}
@@ -58,8 +149,8 @@ export const AuthDropdown: FC = () => {
return;
}
const newAuthentication = models.request.newAuth(type, authentication);
const defaultAuthentication = models.request.newAuth(authentication.type);
const newAuthentication = makeNewAuth(type, authentication);
const defaultAuthentication = makeNewAuth(authentication.type);
// Prompt the user if fields will change between new and old
for (const key of Object.keys(authentication)) {
@@ -80,16 +171,13 @@ export const AuthDropdown: FC = () => {
break;
}
}
update(activeRequest, { authentication:newAuthentication });
update(activeRequest, { authentication: newAuthentication });
}, [activeRequest]);
const isCurrent = useCallback((type: string) => {
if (!activeRequest) {
const isCurrent = useCallback((type: AuthType) => {
if (!activeRequest || !('authentication' in activeRequest)) {
return false;
}
if (!('authentication' in activeRequest)) {
return false;
}
return type === (activeRequest.authentication.type || AUTH_NONE);
return type === (activeRequest.authentication.type || 'none');
}, [activeRequest]);
if (!activeRequest) {
@@ -101,22 +189,25 @@ export const AuthDropdown: FC = () => {
return (
<Dropdown beside>
<DropdownDivider>Auth Types</DropdownDivider>
<DropdownButton className="tall">
<DropdownButton className="tall" disabled={disabled}>
{'authentication' in activeRequest ? getAuthTypeName(activeRequest.authentication.type) || 'Auth' : 'Auth'}
<i className="fa fa-caret-down space-left" />
</DropdownButton>
<AuthItem type={AUTH_BASIC} {...itemProps} />
<AuthItem type={AUTH_DIGEST} {...itemProps} />
<AuthItem type={AUTH_OAUTH_1} {...itemProps} />
<AuthItem type={AUTH_OAUTH_2} {...itemProps} />
<AuthItem type={AUTH_NTLM} {...itemProps} />
<AuthItem type={AUTH_AWS_IAM} {...itemProps} />
<AuthItem type={AUTH_BEARER} {...itemProps} />
<AuthItem type={AUTH_HAWK} {...itemProps} />
<AuthItem type={AUTH_ASAP} {...itemProps} />
<AuthItem type={AUTH_NETRC} {...itemProps} />
<DropdownDivider>Other</DropdownDivider>
<AuthItem type={AUTH_NONE} nameOverride="No Authentication" {...itemProps} />
{authTypes.map(authType =>
<AuthItem
key={authType}
type={authType}
{...itemProps}
/>)}
<DropdownDivider key="divider-other">
Other
</DropdownDivider>
<AuthItem
key="none"
type="none"
nameOverride="No Authentication"
{...itemProps}
/>
</Dropdown>
);
};

View File

@@ -15,6 +15,7 @@ import {
CONTENT_TYPE_YAML,
getContentTypeName,
} from '../../../common/constants';
import { isWebSocketRequest } from '../../../models/websocket-request';
import { selectActiveRequest } from '../../redux/selectors';
import { Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
@@ -38,7 +39,9 @@ const MimeTypeItem: FC<{
mimeType,
onChange,
}) => {
const activeRequest = useSelector(selectActiveRequest);
const request = useSelector(selectActiveRequest);
const activeRequest = request && !isWebSocketRequest(request) ? request : null;
const handleChangeMimeType = useCallback(async (mimeType: string | null) => {
if (!activeRequest) {
return;
@@ -91,7 +94,8 @@ const MimeTypeItem: FC<{
MimeTypeItem.displayName = DropdownItem.name;
export const ContentTypeDropdown: FC<Props> = ({ onChange }) => {
const activeRequest = useSelector(selectActiveRequest);
const request = useSelector(selectActiveRequest);
const activeRequest = request && !isWebSocketRequest(request) ? request : null;
if (!activeRequest) {
return null;

View File

@@ -6,6 +6,7 @@ import { getPreviewModeName, PREVIEW_MODES, PreviewMode } from '../../../common/
import { exportHarCurrentRequest } from '../../../common/har';
import * as models from '../../../models';
import { isRequest } from '../../../models/request';
import { isResponse } from '../../../models/response';
import { selectActiveRequest, selectActiveResponse, selectResponsePreviewMode } from '../../redux/selectors';
import { Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
@@ -36,7 +37,7 @@ export const PreviewModeDropdown: FC<Props> = ({
const handleDownloadNormal = useCallback(() => download(false), [download]);
const exportAsHAR = useCallback(async () => {
if (!response || !request || !isRequest(request)) {
if (!response || !request || !isRequest(request) || !isResponse(response)) {
console.warn('Nothing to download');
return;
}
@@ -61,7 +62,7 @@ export const PreviewModeDropdown: FC<Props> = ({
}, [request, response]);
const exportDebugFile = useCallback(async () => {
if (!response || !request) {
if (!response || !request || !isResponse(response)) {
console.warn('Nothing to download');
return;
}

View File

@@ -47,7 +47,10 @@ export const RequestGroupActionsDropdown = forwardRef<RequestGroupActionsDropdow
const create = useCallback((requestType: CreateRequestType) => {
if (activeWorkspaceId) {
createRequest({ parentId: requestGroup._id, requestType, workspaceId: activeWorkspaceId });
createRequest({
parentId: requestGroup._id,
requestType, workspaceId: activeWorkspaceId,
});
}
}, [activeWorkspaceId, requestGroup._id]);
@@ -143,6 +146,10 @@ export const RequestGroupActionsDropdown = forwardRef<RequestGroupActionsDropdow
<i className="fa fa-plus-circle" />New gRPC Request
</DropdownItem>
<DropdownItem value="WebSocket" onClick={create}>
<i className="fa fa-plus-circle" />WebSocket Request
</DropdownItem>
<DropdownItem onClick={createGroup}>
<i className="fa fa-folder" /> New Folder
<DropdownHint keyBindings={hotKeyRegistry[hotKeyRefs.REQUEST_SHOW_CREATE_FOLDER.id]} />

View File

@@ -1,12 +1,13 @@
import { differenceInHours, differenceInMinutes, isThisWeek, isToday } from 'date-fns';
import React, { FC, Fragment, useCallback, useRef } from 'react';
import React, { Fragment, useCallback, useRef } from 'react';
import { useSelector } from 'react-redux';
import { hotKeyRefs } from '../../../common/hotkeys';
import { executeHotKey } from '../../../common/hotkeys-listener';
import { decompressObject } from '../../../common/misc';
import * as models from '../../../models/index';
import type { Response } from '../../../models/response';
import { Response } from '../../../models/response';
import { isWebSocketResponse, WebSocketResponse } from '../../../models/websocket-response';
import { selectActiveEnvironment, selectActiveRequest, selectActiveRequestResponses, selectRequestVersions } from '../../redux/selectors';
import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
@@ -20,26 +21,26 @@ import { TimeTag } from '../tags/time-tag';
import { URLTag } from '../tags/url-tag';
import { TimeFromNow } from '../time-from-now';
interface Props {
activeResponse: Response;
handleSetActiveResponse: (requestId: string, activeResponse: Response | null) => void;
interface Props<GenericResponse extends Response | WebSocketResponse> {
activeResponse: GenericResponse;
handleSetActiveResponse: (requestId: string, activeResponse: GenericResponse | null) => void;
className?: string;
requestId: string;
}
export const ResponseHistoryDropdown: FC<Props> = ({
export const ResponseHistoryDropdown = <GenericResponse extends Response | WebSocketResponse>({
activeResponse,
handleSetActiveResponse,
className,
requestId,
}) => {
}: Props<GenericResponse>) => {
const dropdownRef = useRef<DropdownHandle>(null);
const activeEnvironment = useSelector(selectActiveEnvironment);
const responses = useSelector(selectActiveRequestResponses);
const responses = useSelector(selectActiveRequestResponses) as GenericResponse[];
const activeRequest = useSelector(selectActiveRequest);
const requestVersions = useSelector(selectRequestVersions);
const now = new Date();
const categories: Record<string, Response[]> = {
const categories: Record<string, GenericResponse[]> = {
minutes: [],
hours: [],
today: [],
@@ -49,16 +50,24 @@ export const ResponseHistoryDropdown: FC<Props> = ({
const handleDeleteResponses = useCallback(async () => {
const environmentId = activeEnvironment ? activeEnvironment._id : null;
await models.response.removeForRequest(requestId, environmentId);
if (isWebSocketResponse(activeResponse)) {
await models.webSocketResponse.removeForRequest(requestId, environmentId);
} else {
await models.response.removeForRequest(requestId, environmentId);
}
if (activeRequest && activeRequest._id === requestId) {
handleSetActiveResponse(requestId, null);
}
}, [activeEnvironment, activeRequest, handleSetActiveResponse, requestId]);
}, [activeEnvironment, activeRequest, activeResponse, handleSetActiveResponse, requestId]);
const handleDeleteResponse = useCallback(async () => {
if (activeResponse) {
await models.response.remove(activeResponse);
if (isWebSocketResponse(activeResponse)) {
await models.webSocketResponse.remove(activeResponse);
} else {
await models.response.remove(activeResponse);
}
}
handleSetActiveResponse(requestId, null);
}, [activeResponse, handleSetActiveResponse, requestId]);
@@ -89,7 +98,7 @@ export const ResponseHistoryDropdown: FC<Props> = ({
categories.other.push(response);
});
const renderResponseRow = (response: Response) => {
const renderResponseRow = (response: GenericResponse) => {
const activeResponseId = activeResponse ? activeResponse._id : 'n/a';
const active = response._id === activeResponseId;
const requestVersion = requestVersions.find(({ _id }) => _id === response.requestVersionId);
@@ -114,12 +123,14 @@ export const ResponseHistoryDropdown: FC<Props> = ({
tooltipDelay={1000}
/>
<TimeTag milliseconds={response.elapsedTime} small tooltipDelay={1000} />
<SizeTag
bytesRead={response.bytesRead}
bytesContent={response.bytesContent}
small
tooltipDelay={1000}
/>
{!isWebSocketResponse(response) && (
<SizeTag
bytesRead={response.bytesRead}
bytesContent={response.bytesContent}
small
tooltipDelay={1000}
/>
)}
{!response.requestVersionId ?
<i
className="icon fa fa-info-circle"

View File

@@ -0,0 +1,29 @@
import React, { FC } from 'react';
import { CONTENT_TYPE_JSON, CONTENT_TYPE_PLAINTEXT } from '../../../common/constants';
import { Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownItem } from '../base/dropdown/dropdown-item';
interface Props {
previewMode: string;
onClick: (previewMode: string) => void;
}
export const WebSocketPreviewModeDropdown: FC<Props> = ({ previewMode, onClick }) => {
return (
<Dropdown>
<DropdownButton className="tall">
{{
[CONTENT_TYPE_JSON]: 'JSON',
[CONTENT_TYPE_PLAINTEXT]: 'Raw',
}[previewMode]}
<i className="fa fa-caret-down space-left" />
</DropdownButton>
<DropdownItem onClick={onClick} value={CONTENT_TYPE_JSON}>
JSON
</DropdownItem>
<DropdownItem onClick={onClick} value={CONTENT_TYPE_PLAINTEXT}>
Raw
</DropdownItem>
</Dropdown>
);
};

View File

@@ -0,0 +1,74 @@
import React, { forwardRef, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { hotKeyRefs } from '../../../common/hotkeys';
import * as requestOperations from '../../../models/helpers/request-operations';
import { incrementDeletedRequests } from '../../../models/stats';
import { WebSocketRequest } from '../../../models/websocket-request';
import { updateRequestMetaByParentId } from '../../hooks/create-request';
import { selectHotKeyRegistry } from '../../redux/selectors';
import { type DropdownHandle, type DropdownProps, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownHint } from '../base/dropdown/dropdown-hint';
import { DropdownItem } from '../base/dropdown/dropdown-item';
import { PromptButton } from '../base/prompt-button';
interface Props extends Pick<DropdownProps, 'right'> {
handleDuplicateRequest: Function;
isPinned: Boolean;
request: WebSocketRequest;
}
export const WebSocketRequestActionsDropdown = forwardRef<DropdownHandle, Props>(({
handleDuplicateRequest,
isPinned,
request,
right,
}, ref) => {
const hotKeyRegistry = useSelector(selectHotKeyRegistry);
const duplicate = useCallback(() => {
handleDuplicateRequest(request);
}, [handleDuplicateRequest, request]);
const togglePin = useCallback(() => {
updateRequestMetaByParentId(request._id, { pinned: !isPinned });
}, [isPinned, request]);
const deleteRequest = useCallback(() => {
incrementDeletedRequests();
requestOperations.remove(request);
}, [request]);
return (
<Dropdown right={right} ref={ref}>
<DropdownButton>
<i className="fa fa-caret-down" />
</DropdownButton>
<DropdownItem onClick={duplicate}>
<i className="fa fa-copy" /> Duplicate
<DropdownHint keyBindings={hotKeyRegistry[hotKeyRefs.REQUEST_SHOW_DUPLICATE.id]} />
</DropdownItem>
<DropdownItem onClick={togglePin}>
<i className="fa fa-thumb-tack" /> {isPinned ? 'Unpin' : 'Pin'}
<DropdownHint keyBindings={hotKeyRegistry[hotKeyRefs.REQUEST_TOGGLE_PIN.id]} />
</DropdownItem>
<DropdownItem
buttonClass={PromptButton}
onClick={deleteRequest}
addIcon
>
<i className="fa fa-trash-o" /> Delete
<DropdownHint keyBindings={hotKeyRegistry[hotKeyRefs.REQUEST_SHOW_DELETE.id]} />
</DropdownItem>
<DropdownDivider />
</Dropdown>
);
});
WebSocketRequestActionsDropdown.displayName = 'WebSocketRequestActionsDropdown';

View File

@@ -3,9 +3,11 @@ import React, { FC } from 'react';
import { AuthInputRow } from './components/auth-input-row';
import { AuthPrivateKeyRow } from './components/auth-private-key-row';
import { AuthTableBody } from './components/auth-table-body';
import { AuthToggleRow } from './components/auth-toggle-row';
export const AsapAuth: FC = () => (
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert />
<AuthInputRow label='Issuer (iss)' property='issuer' />
<AuthInputRow label='Subject (sub)' property='subject' />
<AuthInputRow label='Audience (aud)' property='audience' />

View File

@@ -13,7 +13,6 @@ import {
AUTH_OAUTH_1,
AUTH_OAUTH_2,
} from '../../../../common/constants';
import { isRequest } from '../../../../models/request';
import { selectActiveRequest } from '../../../redux/selectors';
import { AsapAuth } from './asap-auth';
import { AWSAuth } from './aws-auth';
@@ -26,10 +25,10 @@ import { NTLMAuth } from './ntlm-auth';
import { OAuth1Auth } from './o-auth-1-auth';
import { OAuth2Auth } from './o-auth-2-auth';
export const AuthWrapper: FC = () => {
export const AuthWrapper: FC<{ disabled?: boolean }> = ({ disabled = false }) => {
const request = useSelector(selectActiveRequest);
if (!request || !isRequest(request)) {
if (!request || !('authentication' in request)) {
return null;
}
@@ -38,7 +37,7 @@ export const AuthWrapper: FC = () => {
let authBody: ReactNode = null;
if (type === AUTH_BASIC) {
authBody = <BasicAuth />;
authBody = <BasicAuth disabled={disabled} />;
} else if (type === AUTH_OAUTH_2) {
authBody = <OAuth2Auth />;
} else if (type === AUTH_HAWK) {
@@ -46,11 +45,11 @@ export const AuthWrapper: FC = () => {
} else if (type === AUTH_OAUTH_1) {
authBody = <OAuth1Auth />;
} else if (type === AUTH_DIGEST) {
authBody = <DigestAuth />;
authBody = <DigestAuth disabled={disabled} />;
} else if (type === AUTH_NTLM) {
authBody = <NTLMAuth />;
} else if (type === AUTH_BEARER) {
authBody = <BearerAuth />;
authBody = <BearerAuth disabled={disabled} />;
} else if (type === AUTH_AWS_IAM) {
authBody = <AWSAuth />;
} else if (type === AUTH_NETRC) {

View File

@@ -2,9 +2,11 @@ import React, { FC } from 'react';
import { AuthInputRow } from './components/auth-input-row';
import { AuthTableBody } from './components/auth-table-body';
import { AuthToggleRow } from './components/auth-toggle-row';
export const AWSAuth: FC = () => (
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert />
<AuthInputRow
label="Access Key ID"
property="accessKeyId"

View File

@@ -4,14 +4,16 @@ import { AuthInputRow } from './components/auth-input-row';
import { AuthTableBody } from './components/auth-table-body';
import { AuthToggleRow } from './components/auth-toggle-row';
export const BasicAuth: FC = () => (
export const BasicAuth: FC<{ disabled?: boolean }> = ({ disabled = false }) => (
<AuthTableBody>
<AuthInputRow label="Username" property="username" />
<AuthInputRow label="Password" property="password" mask />
<AuthToggleRow label="Enabled" property="disabled" invert disabled={disabled} />
<AuthInputRow label="Username" property="username" disabled={disabled} />
<AuthInputRow label="Password" property="password" mask disabled={disabled} />
<AuthToggleRow
label="Use ISO 8859-1"
help="Check this to use ISO-8859-1 encoding instead of default UTF-8"
property='useISO88591'
disabled={disabled}
/>
</AuthTableBody>
);

View File

@@ -2,10 +2,12 @@ import React, { FC } from 'react';
import { AuthInputRow } from './components/auth-input-row';
import { AuthTableBody } from './components/auth-table-body';
import { AuthToggleRow } from './components/auth-toggle-row';
export const BearerAuth: FC = () => (
export const BearerAuth: FC<{ disabled?: boolean }> = ({ disabled = false }) => (
<AuthTableBody>
<AuthInputRow label='Token' property='token' />
<AuthInputRow label='Prefix' property='prefix' />
<AuthToggleRow label="Enabled" property="disabled" invert disabled={disabled} />
<AuthInputRow label='Token' property='token' disabled={disabled} />
<AuthInputRow label='Prefix' property='prefix' disabled={disabled} />
</AuthTableBody>
);

View File

@@ -14,9 +14,10 @@ interface Props extends Pick<ComponentProps<typeof OneLineEditor>, 'getAutocompl
property: string;
help?: ReactNode;
mask?: boolean;
disabled?: boolean;
}
export const AuthInputRow: FC<Props> = ({ label, getAutocompleteConstants, property, mask, mode, help }) => {
export const AuthInputRow: FC<Props> = ({ label, getAutocompleteConstants, property, mask, mode, help, disabled = false }) => {
const { showPasswords } = useSelector(selectSettings);
const { activeRequest: { authentication }, patchAuth } = useActiveRequest();
@@ -32,13 +33,14 @@ export const AuthInputRow: FC<Props> = ({ label, getAutocompleteConstants, prope
const id = toKebabCase(label);
return (
<AuthRow labelFor={id} label={label} help={help}>
<AuthRow labelFor={id} label={label} help={help} disabled={disabled}>
<OneLineEditor
id={id}
type={isMasked ? 'password' : 'text'}
mode={mode}
onChange={onChange}
disabled={authentication.disabled}
readOnly={disabled}
defaultValue={authentication[property] || ''}
getAutocompleteConstants={getAutocompleteConstants}
/>
@@ -47,6 +49,7 @@ export const AuthInputRow: FC<Props> = ({ label, getAutocompleteConstants, prope
className="btn btn--super-duper-compact pointer"
onClick={onClick}
value={isMasked}
disabled={disabled}
>
{isMasked ? <i className="fa fa-eye" data-testid="reveal-password-icon" /> : <i className="fa fa-eye-slash" data-testid="mask-password-icon" />}
</Button>

View File

@@ -1,12 +1,9 @@
import React, { FC, ReactNode } from 'react';
import { AuthEnabledRow } from './auth-enabled-row';
export const AuthTableBody: FC<{children: ReactNode}> = ({ children }) => (
<div className="pad">
<table>
<tbody>
<AuthEnabledRow />
{children}
</tbody>
</table>

View File

@@ -12,6 +12,7 @@ interface Props {
help?: ReactNode;
onTitle?: string;
offTitle?: string;
disabled?: boolean;
}
const ToggleIcon: FC<{isOn: boolean}> = ({ isOn }) => isOn ? <i data-testid="toggle-is-on" className="fa fa-check-square-o" /> : <i data-testid="toggle-is-off" className="fa fa-square-o" />;
@@ -23,6 +24,7 @@ export const AuthToggleRow: FC<Props> = ({
invert,
onTitle = 'Disable item',
offTitle = 'Enable item',
disabled = false,
}) => {
const { activeRequest: { authentication }, patchAuth } = useActiveRequest();
@@ -38,13 +40,14 @@ export const AuthToggleRow: FC<Props> = ({
const title = isActuallyOn ? onTitle : offTitle;
return (
<AuthRow labelFor={id} label={label} help={help}>
<AuthRow labelFor={id} label={label} help={help} disabled={disabled}>
<Button
className="btn btn--super-duper-compact"
id={id}
onClick={toggle}
value={!databaseValue}
title={title}
disabled={disabled}
>
<ToggleIcon isOn={isActuallyOn} />
</Button>

View File

@@ -2,10 +2,12 @@ import React, { FC } from 'react';
import { AuthInputRow } from './components/auth-input-row';
import { AuthTableBody } from './components/auth-table-body';
import { AuthToggleRow } from './components/auth-toggle-row';
export const DigestAuth: FC = () => (
export const DigestAuth: FC<{ disabled?: boolean }> = ({ disabled = false }) => (
<AuthTableBody>
<AuthInputRow label='Username' property='username' />
<AuthInputRow label='Password' property='password' mask />
<AuthToggleRow label="Enabled" property="disabled" invert disabled={disabled} />
<AuthInputRow label='Username' property='username' disabled={disabled} />
<AuthInputRow label='Password' property='password' mask disabled={disabled} />
</AuthTableBody>
);

View File

@@ -11,6 +11,7 @@ import { AuthToggleRow } from './components/auth-toggle-row';
export const HawkAuth: FC = () => (
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert />
<AuthInputRow label='Auth Id' property='id' />
<AuthInputRow label='Auth Key' property='key' />
<AuthSelectRow

View File

@@ -2,9 +2,11 @@ import React, { FC } from 'react';
import { AuthInputRow } from './components/auth-input-row';
import { AuthTableBody } from './components/auth-table-body';
import { AuthToggleRow } from './components/auth-toggle-row';
export const NTLMAuth: FC = () => (
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert />
<AuthInputRow label="Username" property="username" />
<AuthInputRow label="Password" property="password" mask />
</AuthTableBody>

View File

@@ -37,6 +37,7 @@ export const OAuth1Auth: FC = () => {
return (
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert />
<AuthInputRow label='Consumer Key' property='consumerKey' />
<AuthInputRow label='Consumer Secret' property='consumerSecret' />
<AuthInputRow label='Token Key' property='tokenKey' />

View File

@@ -255,6 +255,7 @@ export const OAuth2Auth: FC = () => {
return (
<>
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert />
<AuthSelectRow
label='Grant Type'
property='grantType'

View File

@@ -3,17 +3,20 @@ import React, { FC, useCallback } from 'react';
import { getCommonHeaderNames, getCommonHeaderValues } from '../../../common/common-headers';
import { update } from '../../../models/helpers/request-operations';
import type { Request, RequestHeader } from '../../../models/request';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { CodeEditor } from '../codemirror/code-editor';
import { KeyValueEditor } from '../key-value-editor/key-value-editor';
interface Props {
bulk: boolean;
request: Request;
isDisabled?: boolean;
request: Request | WebSocketRequest;
}
export const RequestHeadersEditor: FC<Props> = ({
request,
bulk,
isDisabled,
}) => {
const handleBulkUpdate = useCallback((headersString: string) => {
const headers: {
@@ -82,6 +85,8 @@ export const RequestHeadersEditor: FC<Props> = ({
handleGetAutocompleteNameConstants={getCommonHeaderNames}
handleGetAutocompleteValueConstants={getCommonHeaderValues}
onChange={onChangeHeaders}
isDisabled={isDisabled}
isWebSocketRequest={isWebSocketRequest(request)}
/>
</div>
</div>

View File

@@ -1,14 +1,16 @@
import React, { FC, SyntheticEvent, useCallback } from 'react';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import type { Request } from '../../../models/request';
import { isRequest, Request } from '../../../models/request';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { GrpcTag } from '../tags/grpc-tag';
import { MethodTag } from '../tags/method-tag';
import { WebSocketTag } from '../tags/websocket-tag';
interface Props {
handleSetItemSelected: (...args: any[]) => any;
isSelected: boolean;
request: Request | GrpcRequest;
request: Request | WebSocketRequest | GrpcRequest;
}
export const RequestRow: FC<Props> = ({
@@ -27,7 +29,9 @@ export const RequestRow: FC<Props> = ({
<input type="checkbox" checked={isSelected} onChange={onChange} />
</div>
<button className="wide">
{isGrpcRequest(request) ? <GrpcTag /> : <MethodTag method={request.method} />}
{isRequest(request) ? <MethodTag method={request.method} /> : null}
{isGrpcRequest(request) ? <GrpcTag /> : null}
{isWebSocketRequest(request) ? <WebSocketTag /> : null}
<span className="inline-block">{request.name}</span>
</button>
</div>

View File

@@ -2,6 +2,7 @@ import React, { FC } from 'react';
import { isGrpcRequest } from '../../../models/grpc-request';
import { isRequest } from '../../../models/request';
import { isWebSocketRequest } from '../../../models/websocket-request';
import type { Node } from '../modals/export-requests-modal';
import { RequestGroupRow } from './request-group-row';
import { RequestRow } from './request-row';
@@ -18,7 +19,7 @@ export const Tree: FC<Props> = ({ root, handleSetRequestGroupCollapsed, handleSe
return null;
}
if (isRequest(node.doc) || isGrpcRequest(node.doc)) {
if (isRequest(node.doc) || isWebSocketRequest(node.doc) || isGrpcRequest(node.doc)) {
return (
<RequestRow
key={node.doc._id}

View File

@@ -41,6 +41,8 @@ interface Props {
onDelete?: Function;
onCreate?: Function;
className?: string;
isDisabled?: boolean;
isWebSocketRequest?: boolean;
}
interface State {
@@ -405,7 +407,7 @@ export class KeyValueEditor extends PureComponent<Props, State> {
_setFocusedPair(pair: Pair) {
if (pair) {
this._focusedPairId = pair.id;
this._focusedPairId = pair.id || 'n/a';
} else {
this._focusedPairId = null;
}
@@ -435,11 +437,36 @@ export class KeyValueEditor extends PureComponent<Props, State> {
allowMultiline,
sortable,
disableDelete,
isDisabled,
isWebSocketRequest,
} = this.props;
const { pairs } = this.state;
const classes = classnames('key-value-editor', 'wide', className);
const hasMaxPairsAndNotExceeded = !maxPairs || pairs.length < maxPairs;
const showNewHeaderInput = !isDisabled && hasMaxPairsAndNotExceeded;
const readOnlyPairs = [
{ name: 'Connection', value: 'Upgrade' },
{ name: 'Upgrade', value: 'websocket' },
{ name: 'Sec-WebSocket-Key', value: '<calculated at runtime>' },
{ name: 'Sec-WebSocket-Version', value: '13' },
{ name: 'Sec-WebSocket-Extensions', value: 'permessage-deflate; client_max_window_bits' },
];
return (
<ul className={classes}>
{isWebSocketRequest ? readOnlyPairs.map((pair, i) => (
<Row
key={i}
index={i}
sortable={true}
displayDescription={this.state.displayDescription}
descriptionPlaceholder={descriptionPlaceholder}
readOnly
hideButtons
forceInput
pair={pair}
/>
)) : null}
{pairs.map((pair, i) => (
<Row
noDelete={disableDelete}
@@ -466,17 +493,18 @@ export class KeyValueEditor extends PureComponent<Props, State> {
handleGetAutocompleteValueConstants={handleGetAutocompleteValueConstants}
allowMultiline={allowMultiline}
allowFile={allowFile}
readOnly={isDisabled}
hideButtons={isDisabled}
pair={pair}
/>
))}
{!maxPairs || pairs.length < maxPairs ? (
{showNewHeaderInput ? (
<Row
key="empty-row"
hideButtons
sortable
noDropZone
readOnly
forceInput
index={-1}
onChange={noop}
@@ -502,11 +530,9 @@ export class KeyValueEditor extends PureComponent<Props, State> {
onFocusDescription={this._handleAddFromDescription}
allowMultiline={allowMultiline}
allowFile={allowFile}
// @ts-expect-error -- TSCONVERSION missing defaults
pair={{
name: '',
value: '',
description: '',
}}
/>
) : null}

View File

@@ -19,10 +19,10 @@ import { CodePromptModal } from '../modals/code-prompt-modal';
import { showModal } from '../modals/index';
export interface Pair {
id: string;
id?: string;
name: string;
value: string;
description: string;
description?: string;
fileName?: string;
type?: string;
disabled?: boolean;
@@ -34,11 +34,11 @@ export type AutocompleteHandler = (pair: Pair) => string[] | PromiseLike<string[
type DragDirection = 0 | 1 | -1;
interface Props {
onChange: (pair: Pair) => void;
onDelete: (pair: Pair) => void;
onFocusName: (pair: Pair, event: FocusEvent | React.FocusEvent<Element, Element>) => void;
onFocusValue: (pair: Pair, event: FocusEvent | React.FocusEvent<Element, Element>) => void;
onFocusDescription: (pair: Pair, event: FocusEvent | React.FocusEvent<Element, Element>) => void;
onChange?: (pair: Pair) => void;
onDelete?: (pair: Pair) => void;
onFocusName?: (pair: Pair, event: FocusEvent | React.FocusEvent<Element, Element>) => void;
onFocusValue?: (pair: Pair, event: FocusEvent | React.FocusEvent<Element, Element>) => void;
onFocusDescription?: (pair: Pair, event: FocusEvent | React.FocusEvent<Element, Element>) => void;
displayDescription: boolean;
index: number;
pair: Pair;
@@ -195,15 +195,15 @@ class KeyValueEditorRowInternal extends PureComponent<Props, State> {
}
_handleFocusName(event: FocusEvent | React.FocusEvent<Element, Element>) {
this.props.onFocusName(this.props.pair, event);
this.props.onFocusName?.(this.props.pair, event);
}
_handleFocusValue(event: FocusEvent | React.FocusEvent<Element, Element>) {
this.props.onFocusValue(this.props.pair, event);
this.props.onFocusValue?.(this.props.pair, event);
}
_handleFocusDescription(event: FocusEvent | React.FocusEvent<Element, Element>) {
this.props.onFocusDescription(this.props.pair, event);
this.props.onFocusDescription?.(this.props.pair, event);
}
_handleBlurName(event: FocusEvent | React.FocusEvent<Element, Element>) {

View File

@@ -8,6 +8,7 @@ import * as models from '../../../models';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import { isRequest, Request } from '../../../models/request';
import { isRequestGroup, RequestGroup } from '../../../models/request-group';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { RootState } from '../../redux/modules';
import { exportRequestsToFile } from '../../redux/modules/global';
import { selectSidebarChildren } from '../../redux/sidebar-selectors';
@@ -18,7 +19,7 @@ import { ModalHeader } from '../base/modal-header';
import { Tree } from '../export-requests/tree';
export interface Node {
doc: Request | GrpcRequest | RequestGroup;
doc: Request | WebSocketRequest | GrpcRequest | RequestGroup;
children: Node[];
collapsed: boolean;
totalRequests: number;
@@ -67,7 +68,7 @@ export class ExportRequestsModalClass extends PureComponent<Props, State> {
}
getSelectedRequestIds(node: Node): string[] {
const docIsRequest = isRequest(node.doc) || isGrpcRequest(node.doc);
const docIsRequest = isRequest(node.doc) || isWebSocketRequest(node.doc) || isGrpcRequest(node.doc);
if (docIsRequest && node.selectedRequests === node.totalRequests) {
return [node.doc._id];
@@ -117,7 +118,7 @@ export class ExportRequestsModalClass extends PureComponent<Props, State> {
let totalRequests = children
.map(child => child.totalRequests)
.reduce((acc, totalRequests) => acc + totalRequests, 0);
const docIsRequest = isRequest(item.doc) || isGrpcRequest(item.doc);
const docIsRequest = isRequest(item.doc) || isWebSocketRequest(item.doc) || isGrpcRequest(item.doc);
if (docIsRequest) {
totalRequests++;

View File

@@ -4,6 +4,7 @@ import React, { PureComponent } from 'react';
import { AUTOBIND_CFG } from '../../../common/constants';
import { docsTemplateTags } from '../../../common/documentation';
import { isRequest } from '../../../models/request';
import { Link } from '../base/link';
import { Modal } from '../base/modal';
import { ModalBody } from '../base/modal-body';
@@ -58,7 +59,7 @@ export class RequestRenderErrorModal extends PureComponent<{}, State> {
Failed to render <strong>{fullPath}</strong> prior to sending
</p>
<div className="pad-top-sm">
{error.path.match(/^body/) && (
{error.path.match(/^body/) && isRequest(request) && (
<button
className="btn btn--clicky margin-right-sm"
onClick={this._handleShowRequestSettings}

View File

@@ -14,6 +14,7 @@ import * as models from '../../../models';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import { isRequest, Request } from '../../../models/request';
import { isRequestGroup, RequestGroup } from '../../../models/request-group';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { Workspace } from '../../../models/workspace';
import { updateRequestMetaByParentId } from '../../hooks/create-request';
import { RootState } from '../../redux/modules';
@@ -51,7 +52,7 @@ const mapDispatchToProps = (dispatch: Dispatch<AnyAction>) => {
interface State {
searchString: string;
workspacesForActiveProject: Workspace[];
matchedRequests: (Request | GrpcRequest)[];
matchedRequests: (Request | WebSocketRequest | GrpcRequest)[];
matchedWorkspaces: Workspace[];
activeIndex: number;
maxRequests: number;
@@ -167,7 +168,7 @@ class RequestSwitcherModal extends PureComponent<ReduxProps, State> {
this.modal?.hide();
}
async _activateRequest(request?: Request | GrpcRequest) {
async _activateRequest(request?: Request | WebSocketRequest | GrpcRequest) {
if (!request) {
return;
}
@@ -184,7 +185,7 @@ class RequestSwitcherModal extends PureComponent<ReduxProps, State> {
}
/** Return array of path segments for given request or folder */
_groupOf(requestOrRequestGroup: Request | GrpcRequest | RequestGroup): string[] {
_groupOf(requestOrRequestGroup: Request | WebSocketRequest | GrpcRequest | RequestGroup): string[] {
const { workspaceRequestsAndRequestGroups } = this.props;
const requestGroups = workspaceRequestsAndRequestGroups.filter(isRequestGroup);
const matchedGroups = requestGroups.filter(g => g._id === requestOrRequestGroup.parentId);
@@ -204,7 +205,7 @@ class RequestSwitcherModal extends PureComponent<ReduxProps, State> {
return this._groupOf(matchedGroups[0]);
}
_isMatch(request: Request | GrpcRequest, searchStrings: string): number | null {
_isMatch(request: Request | WebSocketRequest | GrpcRequest, searchStrings: string): number | null {
let finalUrl = request.url;
let method = '';
@@ -254,7 +255,7 @@ class RequestSwitcherModal extends PureComponent<ReduxProps, State> {
// OPTIMIZATION: This only filters if we have a filter
let matchedRequests = (workspaceRequestsAndRequestGroups
.filter(child => isRequest(child) || isGrpcRequest(child)) as (Request | GrpcRequest)[])
.filter(child => isRequest(child) || isWebSocketRequest(child) || isGrpcRequest(child)) as (Request | WebSocketRequest | GrpcRequest)[])
.sort((a, b) => {
const aLA = lastActiveMap[a._id] || 0;
const bLA = lastActiveMap[b._id] || 0;
@@ -446,7 +447,7 @@ class RequestSwitcherModal extends PureComponent<ReduxProps, State> {
</div>
)}
<ul>
{matchedRequests.map((r: Request | GrpcRequest, i) => {
{matchedRequests.map((r: Request | WebSocketRequest | GrpcRequest, i) => {
const requestGroup = requestGroups.find(rg => rg._id === r.parentId);
const buttonClasses = classnames(
'btn btn--expandable-small wide text-left pad-bottom',

View File

@@ -1,73 +1,80 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { PureComponent } from 'react';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { AUTOBIND_CFG } from '../../../common/constants';
import { ResponseTimelineEntry } from '../../../main/network/libcurl-promise';
import * as models from '../../../models/index';
import type { Response } from '../../../models/response';
import { ResponseTimelineViewer } from '../../components/viewers/response-timeline-viewer';
import { Modal } from '../base/modal';
import { Modal, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalHeader } from '../base/modal-header';
interface State {
response: Response | null;
interface ResponseDebugModalOptions {
responseId?: string;
response?: Response | null;
showBody?: boolean;
title: string | null;
title?: string | null;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class ResponseDebugModal extends PureComponent<{}, State> {
modal: Modal | null = null;
state: State = {
response: null,
showBody: false,
interface State {
responseId?: string;
timeline?: ResponseTimelineEntry[];
title?: string | null;
}
export interface ResponseDebugModalHandle {
show: (options: ResponseDebugModalOptions) => void;
hide: () => void;
}
export const ResponseDebugModal = forwardRef<ResponseDebugModalHandle, ModalProps>((_, ref) => {
const modalRef = useRef<Modal>(null);
const [state, setState] = useState<State>({
responseId: '',
timeline: [],
title: '',
};
});
useImperativeHandle(ref, () => ({
hide: () => {
modalRef.current?.hide();
},
show: async options => {
let response = options.response;
if (!response) {
response = await models.response.getById(options.responseId || 'n/a');
}
if (!response) {
console.error('No response found');
return;
}
const timeline = await models.response.getTimeline(response, options.showBody);
setState({
responseId: response._id,
timeline,
title: options.title || null,
});
modalRef.current?.show();
},
}), []);
const { responseId, timeline, title } = state;
return (
<Modal ref={modalRef} tall>
<ModalHeader>{title || 'Response Timeline'}</ModalHeader>
<ModalBody>
<div
style={{
display: 'grid',
}}
className="tall"
>
{(responseId && timeline) ? (
<ResponseTimelineViewer
key={responseId}
timeline={timeline}
/>
) : (
<div>No response found</div>
)}
</div>
</ModalBody>
</Modal>
);
});
_setModalRef(modal: Modal) {
this.modal = modal;
}
hide() {
this.modal?.hide();
}
async show(options: { responseId?: string; response?: Response; title?: string; showBody?: boolean }) {
const response = options.response
? options.response
: await models.response.getById(options.responseId || 'n/a');
this.setState({
response,
title: options.title || null,
showBody: options.showBody,
});
this.modal?.show();
}
render() {
const { response, title, showBody } = this.state;
return (
<Modal ref={this._setModalRef} tall>
<ModalHeader>{title || 'Response Timeline'}</ModalHeader>
<ModalBody>
<div
style={{
display: 'grid',
}}
className="tall"
>
{response ? (
<ResponseTimelineViewer
response={response}
showBody={showBody}
/>
) : (
<div>No response found</div>
)}
</div>
</ModalBody>
</Modal>
);
}
}
ResponseDebugModal.displayName = 'ResponseDebugModal';

View File

@@ -165,7 +165,7 @@ export const RequestPane: FC<Props> = ({
</Tab>
<Tab tabIndex="-1">
<button>
Header
Headers
{numHeaders > 0 && <span className="bubble space-left">{numHeaders}</span>}
</button>
</Tab>

View File

@@ -41,7 +41,7 @@ export const ResponsePane: FC<Props> = ({
handleSetActiveResponse,
request,
}) => {
const response = useSelector(selectActiveResponse);
const response = useSelector(selectActiveResponse) as Response | null;
const filterHistory = useSelector(selectResponseFilterHistory);
const filter = useSelector(selectResponseFilter);
const settings = useSelector(selectSettings);
@@ -122,7 +122,7 @@ export const ResponsePane: FC<Props> = ({
</PlaceholderResponsePane>
);
}
const timeline = models.response.getTimeline(response);
const cookieHeaders = getSetCookieHeaders(response.headers);
return (
<Pane type="response">
@@ -154,7 +154,7 @@ export const ResponsePane: FC<Props> = ({
</Tab>
<Tab tabIndex="-1">
<Button>
Header{' '}
Headers{' '}
{response.headers.length > 0 && (
<span className="bubble">{response.headers.length}</span>
)}
@@ -162,7 +162,7 @@ export const ResponsePane: FC<Props> = ({
</Tab>
<Tab tabIndex="-1">
<Button>
Cookie{' '}
Cookies{' '}
{cookieHeaders.length ? (
<span className="bubble">{cookieHeaders.length}</span>
) : null}
@@ -212,7 +212,8 @@ export const ResponsePane: FC<Props> = ({
<TabPanel className="react-tabs__tab-panel">
<ErrorBoundary key={response._id} errorClassName="font-error pad text-center">
<ResponseTimelineViewer
response={response}
key={response._id}
timeline={timeline}
/>
</ErrorBoundary>
</TabPanel>

View File

@@ -7,13 +7,14 @@ import * as models from '../../../models';
import { GrpcRequest } from '../../../models/grpc-request';
import { Request } from '../../../models/request';
import { RequestGroup } from '../../../models/request-group';
import { WebSocketRequest } from '../../../models/websocket-request';
export type DnDDragProps = ReturnType<typeof sourceCollect>;
export type DnDDropProps = ReturnType<typeof targetCollect>;
export type DnDProps = DnDDragProps & DnDDropProps;
export interface DragObject {
item?: GrpcRequest | Request | RequestGroup;
item?: GrpcRequest | Request | WebSocketRequest | RequestGroup;
}
export const sourceCollect = (connect: DragSourceConnector, monitor: DragSourceMonitor) => ({

View File

@@ -2,10 +2,11 @@ import React, { FC, Fragment } from 'react';
import ReactDOM from 'react-dom';
import { useSelector } from 'react-redux';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import { GrpcRequest } from '../../../models/grpc-request';
import * as models from '../../../models/index';
import { isRequest, Request } from '../../../models/request';
import type { RequestGroup } from '../../../models/request-group';
import { Request } from '../../../models/request';
import { isRequestGroup, RequestGroup } from '../../../models/request-group';
import { WebSocketRequest } from '../../../models/websocket-request';
import { updateRequestMetaByParentId } from '../../hooks/create-request';
import { selectActiveRequest, selectActiveWorkspaceMeta } from '../../redux/selectors';
import { selectSidebarChildren } from '../../redux/sidebar-selectors';
@@ -14,7 +15,7 @@ import { SidebarRequestGroupRow } from './sidebar-request-group-row';
import { SidebarRequestRow } from './sidebar-request-row';
export interface Child {
doc: Request | GrpcRequest | RequestGroup;
doc: Request | GrpcRequest | WebSocketRequest | RequestGroup;
children: Child[];
collapsed: boolean;
hidden: boolean;
@@ -50,16 +51,37 @@ export const SidebarChildren: FC<Props> = ({
updateRequestMetaByParentId(requestId, { lastActive: Date.now() });
};
const RecursiveSidebarRows: FC<RecursiveSidebarRowsProps> = ({ rows, isInPinnedList }) => {
const RecursiveSidebarRows: FC<RecursiveSidebarRowsProps> = ({
rows,
isInPinnedList,
}) => {
const activeRequest = useSelector(selectActiveRequest);
const activeRequestId = activeRequest ? activeRequest._id : 'n/a';
return (
<>
{rows.map(row => (!isInPinnedList && row.hidden)
? null
: (isRequest(row.doc) || isGrpcRequest(row.doc))
? (
{rows
.filter(row => !(!isInPinnedList && row.hidden))
.map(row => {
if (isRequestGroup(row.doc)) {
return (
<SidebarRequestGroupRow
key={row.doc._id}
filter={filter || ''}
isActive={hasActiveChild(row.children, activeRequestId)}
isCollapsed={row.collapsed}
requestGroup={row.doc}
>
{row.children.filter(Boolean).length > 0 ? (
<RecursiveSidebarRows
isInPinnedList={isInPinnedList}
rows={row.children}
/>
) : null}
</SidebarRequestGroupRow>
);
}
return (
<SidebarRequestRow
key={row.doc._id}
filter={isInPinnedList ? '' : filter || ''}
@@ -70,19 +92,12 @@ export const SidebarChildren: FC<Props> = ({
disableDragAndDrop={isInPinnedList}
request={row.doc}
/>
) : (
<SidebarRequestGroupRow
key={row.doc._id}
filter={filter || ''}
isActive={hasActiveChild(row.children, activeRequestId)}
isCollapsed={row.collapsed}
requestGroup={row.doc}
>
{row.children.filter(Boolean).length > 0 ? <RecursiveSidebarRows isInPinnedList={isInPinnedList} rows={row.children} /> : null}
</SidebarRequestGroupRow>
))}
</>);
);
})}
</>
);
};
const { all, pinned } = sidebarChildren;
const showSeparator = sidebarChildren.pinned.length > 0;
const contextMenuPortal = ReactDOM.createPortal(

View File

@@ -56,6 +56,10 @@ export const SidebarCreateDropdown: FC<Props> = ({ right }) => {
<i className="fa fa-plus-circle" />gRPC Request
</DropdownItem>
<DropdownItem value="WebSocket" onClick={create}>
<i className="fa fa-plus-circle" />WebSocket Request
</DropdownItem>
<DropdownItem onClick={createGroup}>
<i className="fa fa-folder" />New Folder
<DropdownHint keyBindings={hotKeyRegistry[hotKeyRefs.REQUEST_SHOW_CREATE_FOLDER.id]} />

View File

@@ -7,8 +7,9 @@ import { CONTENT_TYPE_GRAPHQL } from '../../../common/constants';
import { getMethodOverrideHeader } from '../../../common/misc';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import * as requestOperations from '../../../models/helpers/request-operations';
import { Request } from '../../../models/request';
import { isRequest, Request } from '../../../models/request';
import { RequestGroup } from '../../../models/request-group';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { useNunjucks } from '../../context/nunjucks/use-nunjucks';
import { createRequest } from '../../hooks/create-request';
import { selectActiveEnvironment, selectActiveProject, selectActiveWorkspace } from '../../redux/selectors';
@@ -16,11 +17,13 @@ import type { DropdownHandle } from '../base/dropdown/dropdown';
import { Editable } from '../base/editable';
import { Highlight } from '../base/highlight';
import { RequestActionsDropdown } from '../dropdowns/request-actions-dropdown';
import { WebSocketRequestActionsDropdown } from '../dropdowns/websocket-request-actions-dropdown';
import { GrpcSpinner } from '../grpc-spinner';
import { showModal } from '../modals/index';
import { RequestSettingsModal } from '../modals/request-settings-modal';
import { GrpcTag } from '../tags/grpc-tag';
import { MethodTag } from '../tags/method-tag';
import { WebSocketTag } from '../tags/websocket-tag';
import { DnDProps, DragObject, dropHandleCreator, hoverHandleCreator, sourceCollect, targetCollect } from './dnd';
interface RawProps {
@@ -30,7 +33,7 @@ interface RawProps {
handleDuplicateRequest: Function;
isActive: boolean;
isPinned: boolean;
request?: Request | GrpcRequest;
request?: Request | GrpcRequest | WebSocketRequest;
requestGroup?: RequestGroup;
}
@@ -138,7 +141,7 @@ export const _SidebarRequestRow: FC<Props> = forwardRef(({
return;
}
if (isGrpcRequest(request)) {
if (!isRequest(request)) {
return;
}
@@ -203,12 +206,17 @@ export const _SidebarRequestRow: FC<Props> = forwardRef(({
</li>
);
} else {
const methodTag =
isGrpcRequest(request) ? (
<GrpcTag />
) : (
<MethodTag method={request.method} override={methodOverrideValue} />
);
let methodTag = null;
if (isGrpcRequest(request)) {
methodTag = <GrpcTag />;
} else if (isWebSocketRequest(request)) {
methodTag = <WebSocketTag />;
} else if (isRequest(request)) {
methodTag = <MethodTag method={request.method} override={methodOverrideValue} />;
}
node = (
<li ref={nodeRef} className={classes}>
<div
@@ -243,17 +251,27 @@ export const _SidebarRequestRow: FC<Props> = forwardRef(({
</div>
</button>
<div className="sidebar__actions">
<RequestActionsDropdown
right
ref={requestActionsDropdown}
handleDuplicateRequest={handleDuplicateRequest}
handleShowSettings={handleShowRequestSettings}
request={request}
isPinned={isPinned}
requestGroup={requestGroup}
activeEnvironment={activeEnvironment}
activeProject={activeProject}
/>
{isWebSocketRequest(request) ? (
<WebSocketRequestActionsDropdown
right
ref={requestActionsDropdown}
handleDuplicateRequest={handleDuplicateRequest}
request={request}
isPinned={isPinned}
/>
) : (
<RequestActionsDropdown
right
ref={requestActionsDropdown}
handleDuplicateRequest={handleDuplicateRequest}
handleShowSettings={handleShowRequestSettings}
request={request}
isPinned={isPinned}
requestGroup={requestGroup}
activeEnvironment={activeEnvironment}
activeProject={activeProject}
/>
)}
</div>
{isPinned && (
<div className="sidebar__item__icon-pin">

View File

@@ -0,0 +1,7 @@
import React from 'react';
export const WebSocketTag = () => (
<div className="tag tag--no-bg tag--small">
<span className="tag__inner" style={{ color: 'var(--color-notice)' }}>WS</span>
</div>
);

View File

@@ -1,94 +1,55 @@
import React, { PureComponent } from 'react';
import React, { FC, useEffect, useRef } from 'react';
import { clickLink } from '../../../common/electron-helpers';
import type { ResponseTimelineEntry } from '../../../main/network/libcurl-promise';
import * as models from '../../../models';
import type { Response } from '../../../models/response';
import { CodeEditor } from '../codemirror/code-editor';
import { CodeEditor, UnconnectedCodeEditor } from '../codemirror/code-editor';
interface Props {
showBody?: boolean;
response: Response;
}
interface State {
timeline: ResponseTimelineEntry[];
timelineKey: string;
}
export class ResponseTimelineViewer extends PureComponent<Props, State> {
state: State = {
timeline: [],
timelineKey: '',
};
export const ResponseTimelineViewer: FC<Props> = ({ timeline }) => {
const editorRef = useRef<UnconnectedCodeEditor>(null);
const rows = timeline
.map(({ name, value }, i, all) => {
const prefixLookup: Record<ResponseTimelineEntry['name'], string> = {
HeaderIn: '< ',
DataIn: '| ',
SslDataIn: '<< ',
HeaderOut: '> ',
DataOut: '| ',
SslDataOut: '>> ',
Text: '* ',
};
const prefix: string = prefixLookup[name] || '* ';
const lines = (value + '').replace(/\n$/, '').split('\n');
const newLines = lines.filter(l => !l.match(/^\s*$/)).map(l => `${prefix}${l}`);
// Prefix each section with a newline to separate them
const previousName = i > 0 ? all[i - 1].name : '';
componentDidMount() {
this.refreshTimeline();
}
const hasNameChanged = previousName !== name;
// Join all lines together
return (hasNameChanged ? '\n' : '') + newLines.join('\n');
})
.filter(r => r !== null)
.join('\n')
.trim();
componentDidUpdate(prevProps: Props) {
const { response } = this.props;
if (response._id !== prevProps.response._id) {
this.refreshTimeline();
useEffect(() => {
if (editorRef.current) {
editorRef.current.codeMirror?.setValue(rows);
}
}
}, [rows]);
async refreshTimeline() {
const { response, showBody } = this.props;
const timeline = models.response.getTimeline(response, showBody);
this.setState({
timeline,
timelineKey: response._id,
});
}
renderRow(row: ResponseTimelineEntry, i: number, all: ResponseTimelineEntry[]) {
const { name, value } = row;
const previousName = i > 0 ? all[i - 1].name : '';
const prefixLookup: Record<ResponseTimelineEntry['name'], string> = {
HeaderIn: '< ',
DataIn: '| ',
SslDataIn: '<< ',
HeaderOut: '> ',
DataOut: '| ',
SslDataOut: '>> ',
Text: '* ',
};
const prefix: string = prefixLookup[name] || '* ';
const lines = (value + '').replace(/\n$/, '').split('\n');
const newLines = lines.filter(l => !l.match(/^\s*$/)).map(l => `${prefix}${l}`);
let leadingSpace = '';
// Prefix each section with a newline to separate them
if (previousName !== name) {
leadingSpace = '\n';
}
// Join all lines together
return leadingSpace + newLines.join('\n');
}
render() {
const { timeline, timelineKey } = this.state;
const rows = timeline
.map(this.renderRow)
.filter(r => r !== null)
.join('\n')
.trim();
return (
<CodeEditor
key={timelineKey}
hideLineNumbers
readOnly
onClickLink={clickLink}
defaultValue={rows}
className="pad-left"
mode="curl"
/>
);
}
}
return (
<CodeEditor
ref={editorRef}
hideLineNumbers
readOnly
onClickLink={clickLink}
defaultValue={rows}
className="pad-left"
mode="curl"
/>
);
};

View File

@@ -0,0 +1,167 @@
import React, { FC, FormEvent } from 'react';
import styled from 'styled-components';
import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../../common/render';
import { WebSocketRequest } from '../../../models/websocket-request';
import { ReadyState } from '../../context/websocket-client/use-ws-ready-state';
import { OneLineEditor } from '../codemirror/one-line-editor';
import { showAlert, showModal } from '../modals';
import { RequestRenderErrorModal } from '../modals/request-render-error-modal';
const Button = styled.button<{ warning?: boolean }>(({ warning }) => ({
paddingRight: 'var(--padding-md)',
paddingLeft: 'var(--padding-md)',
textAlign: 'center',
background: warning ? 'var(--color-danger)' : 'var(--color-surprise)',
color: 'var(--color-font-surprise)',
flex: '0 0 100px',
':hover': {
filter: 'brightness(0.8)',
},
}));
interface ActionButtonProps {
requestId: string;
readyState: ReadyState;
}
const ActionButton: FC<ActionButtonProps> = ({ requestId, readyState }) => {
if (readyState === ReadyState.CONNECTING || readyState === ReadyState.CLOSED) {
return (
<Button
type="submit"
form="websocketUrlForm"
>
Connect
</Button>
);
}
return (
<Button
className="urlbar__send-btn"
type="button"
warning
onClick={() => {
window.main.webSocket.close({ requestId });
}}
>
Disconnect
</Button>
);
};
interface ActionBarProps {
request: WebSocketRequest;
workspaceId: string;
environmentId: string;
defaultValue: string;
readyState: ReadyState;
onChange: (value: string) => void;
}
const Form = styled.form({
flex: 1,
display: 'flex',
});
const StyledUrlBar = styled.div({
boxSizing: 'border-box',
width: '100%',
height: '100%',
paddingRight: 'var(--padding-md)',
paddingLeft: 'var(--padding-md)',
});
const WebSocketIcon = styled.span({
color: 'var(--color-notice)',
display: 'flex',
alignItems: 'center',
paddingLeft: 'var(--padding-md)',
});
const ConnectionStatus = styled.span({
color: 'var(--color-success)',
display: 'flex',
alignItems: 'center',
paddingLeft: 'var(--padding-md)',
});
const ConnectionCircle = styled.span({
backgroundColor: 'var(--color-success)',
marginRight: 'var(--padding-sm)',
width: 10,
height: 10,
borderRadius: '50%',
});
export const WebSocketActionBar: FC<ActionBarProps> = ({ request, workspaceId, environmentId, defaultValue, onChange, readyState }) => {
const isOpen = readyState === ReadyState.OPEN;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const renderContext = await getRenderContext({ request, environmentId, purpose: RENDER_PURPOSE_SEND });
const { url, headers, authentication } = request;
// Render any nunjucks tags in the url/headers/authentication settings
const rendered = await render({
url,
headers,
authentication,
}, renderContext);
window.main.webSocket.create({
requestId: request._id,
workspaceId,
url: rendered.url,
headers: rendered.headers,
authentication: rendered.authentication,
});
} catch (err) {
if (err.type === 'render') {
showModal(RequestRenderErrorModal, {
request,
error: err,
});
} else {
showAlert({
title: 'Unexpected Request Failure',
message: (
<div>
<p>The request failed due to an unhandled error:</p>
<code className="wide selectable">
<pre>{err.message}</pre>
</code>
</div>
),
});
}
}
};
return (
<>
{!isOpen && <WebSocketIcon>WS</WebSocketIcon>}
{isOpen && (
<ConnectionStatus>
<ConnectionCircle />
CONNECTED
</ConnectionStatus>
)}
<Form aria-disabled={isOpen} id="websocketUrlForm" onSubmit={handleSubmit}>
<StyledUrlBar>
<OneLineEditor
disabled={readyState === ReadyState.OPEN}
placeholder="wss://example.com/chat"
defaultValue={defaultValue}
onChange={onChange}
type="text"
forceEditor
/>
</StyledUrlBar>
</Form>
<ActionButton requestId={request._id} readyState={readyState} />
</>
);
};

View File

@@ -0,0 +1,200 @@
import { format } from 'date-fns';
import { SvgIcon, SvgIconProps } from 'insomnia-components';
import React, { FC, useRef } from 'react';
import { useMeasure } from 'react-use';
import { useVirtual } from 'react-virtual';
import styled from 'styled-components';
import { WebSocketEvent } from '../../../main/network/websocket';
const Timestamp: FC<{ time: Date | number }> = ({ time }) => {
const date = format(time, 'HH:mm:ss');
return <>{date}</>;
};
interface Props {
events: WebSocketEvent[];
selectionId?: string;
onSelect: (event: WebSocketEvent) => void;
}
const Divider = styled('div')({
height: '100%',
width: '1px',
backgroundColor: 'var(--hl-md)',
});
const AutoSize = styled.div({
flex: '1 0',
overflow: 'hidden',
});
const Scrollable = styled.div({
overflowY: 'scroll',
});
const HeadingRow = styled('div')({
flex: '0 0 30px',
display: 'flex',
width: '100%',
alignItems: 'center',
borderBottom: '1px solid var(--hl-md)',
paddingRight: 'var(--scrollbar-width)',
boxSizing: 'border-box',
});
const Row = styled('div')<{ isActive: boolean }>(({ isActive }) => ({
position: 'absolute',
top: 0,
left: 0,
height: '30px',
display: 'flex',
width: '100%',
alignItems: 'center',
borderBottom: '1px solid var(--hl-md)',
boxSizing: 'border-box',
backgroundColor: isActive ? 'var(--hl-lg)' : 'transparent',
}));
const List = styled('div')({
width: '100%',
position: 'relative',
});
const EventLog = styled('div')({
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
overflow: 'hidden',
border: '1px solid var(--hl-md)',
});
const EventIconCell = styled('div')({
flex: '0 0 15px',
height: '100%',
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box',
padding: 'var(--padding-xs)',
});
function getIcon(event: WebSocketEvent): SvgIconProps['icon'] {
switch (event.type) {
case 'message': {
if (event.direction === 'OUTGOING') {
return 'sent';
} else {
return 'receive';
}
}
case 'open': {
return 'checkmark-circle';
}
case 'close': {
return 'disconnected';
}
case 'error': {
return 'error';
}
default: {
return 'bug';
}
}
}
const EventMessageCell = styled('div')({
flex: '1 0',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: 'var(--padding-xs)',
});
const getMessage = (event: WebSocketEvent): string => {
switch (event.type) {
case 'message': {
return event.data.toString();
}
case 'open': {
return 'Connected successfully';
}
case 'close': {
return 'Disconnected';
}
case 'error': {
return event.message;
}
default: {
return 'Unknown event';
}
}
};
const EventTimestampCell = styled('div')({
flex: '0 0 80px',
padding: 'var(--padding-xs)',
});
export const EventLogView: FC<Props> = ({ events, onSelect, selectionId }) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtual({
parentRef,
size: events.length,
estimateSize: React.useCallback(() => 30, []),
overscan: 30,
keyExtractor: index => events[index]._id,
});
const [autoSizeRef, { height }] = useMeasure<HTMLDivElement>();
return (
<EventLog>
<HeadingRow>
<EventIconCell>
<div style={{ width: '13px' }} />
</EventIconCell>
<Divider />
<EventMessageCell>Data</EventMessageCell>
<Divider />
<EventTimestampCell>Time</EventTimestampCell>
</HeadingRow>
<AutoSize ref={autoSizeRef}>
<Scrollable style={{ height }} ref={parentRef}>
<List
style={{
height: `${virtualizer.totalSize}px`,
}}
>
{virtualizer.virtualItems.map(item => {
const event = events[item.index];
return (
<Row
key={item.key}
onClick={() => onSelect(event)}
isActive={event._id === selectionId}
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`,
}}
>
<EventIconCell>
<SvgIcon icon={getIcon(event)} />
</EventIconCell>
<Divider />
<EventMessageCell>{getMessage(event)}</EventMessageCell>
<Divider />
<EventTimestampCell>
<Timestamp time={event.timestamp} />
</EventTimestampCell>
</Row>
);
})}
</List>
</Scrollable>
</AutoSize>
</EventLog>
);
};

View File

@@ -0,0 +1,136 @@
import { clipboard } from 'electron';
import fs from 'fs';
import React, { FC, useCallback } from 'react';
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 { WebSocketEvent, WebSocketMessageEvent } from '../../../main/network/websocket';
import { requestMeta } from '../../../models';
import { selectResponsePreviewMode } from '../../redux/selectors';
import { CodeEditor } from '../codemirror/code-editor';
import { showError } from '../modals';
import { WebSocketPreviewModeDropdown } from './websocket-preview-dropdown';
interface Props<T extends WebSocketEvent> {
event: T;
requestId: string;
}
const PreviewPane = styled.div({
display: 'flex',
flexDirection: 'column',
height: '100%',
});
const PreviewPaneButtons = styled.div({
display: 'flex',
flexDirection: 'row',
boxSizing: 'border-box',
height: 'var(--line-height-sm)',
borderBottom: '1px solid var(--hl-lg)',
padding: 'var(--padding-sm) var(--padding-md)',
});
const PreviewPaneContents = styled.div({
padding: 'var(--padding-sm)',
flexGrow: 1,
});
export const MessageEventView: FC<Props<WebSocketMessageEvent>> = ({ event, requestId }) => {
// TODO: Handle non-string data.
const raw = event.data.toString('utf-8');
const handleDownloadResponseBody = useCallback(async () => {
const { canceled, filePath: outputPath } = await window.dialog.showSaveDialog({
title: 'Save Response Body',
buttonLabel: 'Save',
});
if (canceled || !outputPath) {
return;
}
const to = fs.createWriteStream(outputPath);
to.on('error', err => {
showError({
title: 'Save Failed',
message: 'Failed to save response body',
error: err,
});
});
to.write(raw);
to.end();
}, [raw]);
const handleCopyResponseToClipboard = useCallback(() => {
clipboard.writeText(raw);
}, [raw]);
const previewMode = useSelector(selectResponsePreviewMode);
const setPreviewMode = async (previewMode: PreviewMode) => {
return requestMeta.updateOrCreateByParentId(requestId, { previewMode });
};
// TODO(johnwchadwick): Maybe shouldn't try if it's too large.
// TODO(johnwchadwick): Should allow selecting a type instead of assuming JSON.
let pretty = raw;
try {
const parsed = JSON.parse(raw);
pretty = JSON.stringify(parsed, null, '\t');
} catch {
// Can't parse as JSON.
}
return (
<PreviewPane>
<PreviewPaneButtons>
<WebSocketPreviewModeDropdown
download={handleDownloadResponseBody}
copyToClipboard={handleCopyResponseToClipboard}
previewMode={previewMode}
setPreviewMode={setPreviewMode}
/>
</PreviewPaneButtons>
<PreviewPaneContents>
{previewMode === PREVIEW_MODE_FRIENDLY &&
<CodeEditor
hideLineNumbers
mode={'text/json'}
defaultValue={pretty}
uniquenessKey={event._id}
readOnly
/>}
{previewMode === PREVIEW_MODE_SOURCE &&
<CodeEditor
hideLineNumbers
mode={'text/json'}
defaultValue={raw}
uniquenessKey={event._id}
readOnly
/>}
{previewMode === PREVIEW_MODE_RAW &&
<CodeEditor
hideLineNumbers
mode={'text/plain'}
defaultValue={raw}
uniquenessKey={event._id}
readOnly
/>}
</PreviewPaneContents>
</PreviewPane>
);
};
export const EventView: FC<Props<WebSocketEvent>> = ({ event, ...props }) => {
switch (event.type) {
case 'message':
return <MessageEventView event={event} {...props}/>;
default:
return null;
}
};

View File

@@ -0,0 +1,42 @@
import React, { FC } from 'react';
import { getPreviewModeName, PREVIEW_MODES, PreviewMode } from '../../../common/constants';
import { Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownItem } from '../base/dropdown/dropdown-item';
interface Props {
download: () => void;
copyToClipboard: () => void;
previewMode: PreviewMode;
setPreviewMode: (mode: PreviewMode) => void;
}
export const WebSocketPreviewModeDropdown: FC<Props> = ({
download,
copyToClipboard,
previewMode,
setPreviewMode,
}) => {
return <Dropdown beside>
<DropdownButton className="tall">
{getPreviewModeName(previewMode)}
<i className="fa fa-caret-down space-left" />
</DropdownButton>
<DropdownDivider>Preview Mode</DropdownDivider>
{PREVIEW_MODES.map(mode => <DropdownItem key={mode} onClick={setPreviewMode} value={mode}>
{previewMode === mode ? <i className="fa fa-check" /> : <i className="fa fa-empty" />}
{getPreviewModeName(mode, true)}
</DropdownItem>)}
<DropdownDivider>Actions</DropdownDivider>
<DropdownItem onClick={copyToClipboard}>
<i className="fa fa-copy" />
Copy raw response
</DropdownItem>
<DropdownItem onClick={download}>
<i className="fa fa-save" />
Export raw response
</DropdownItem>
</Dropdown>;
};

View File

@@ -0,0 +1,254 @@
import React, { FC, FormEvent, useEffect, useRef, useState } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import styled from 'styled-components';
import { AuthType, CONTENT_TYPE_JSON } from '../../../common/constants';
import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../../common/render';
import * as models from '../../../models';
import { WebSocketRequest } from '../../../models/websocket-request';
import { ReadyState, useWSReadyState } from '../../context/websocket-client/use-ws-ready-state';
import { CodeEditor, UnconnectedCodeEditor } from '../codemirror/code-editor';
import { AuthDropdown } from '../dropdowns/auth-dropdown';
import { WebSocketPreviewModeDropdown } from '../dropdowns/websocket-preview-mode';
import { AuthWrapper } from '../editors/auth/auth-wrapper';
import { RequestHeadersEditor } from '../editors/request-headers-editor';
import { showAlert, showModal } from '../modals';
import { RequestRenderErrorModal } from '../modals/request-render-error-modal';
import { Pane, PaneHeader as OriginalPaneHeader } from '../panes/pane';
import { WebSocketActionBar } from './action-bar';
const supportedAuthTypes: AuthType[] = ['basic', 'bearer'];
const EditorWrapper = styled.div({
height: '100%',
});
const SendMessageForm = styled.form({
width: '100%',
height: '100%',
position: 'relative',
boxSizing: 'border-box',
});
const SendButton = styled.button({
padding: '0 var(--padding-md)',
marginLeft: 'var(--padding-xs)',
height: '100%',
border: '1px solid var(--hl-lg)',
borderRadius: 'var(--radius-md)',
':hover': {
filter: 'brightness(0.8)',
},
});
const PaneSendButton = styled.div({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
boxSizing: 'border-box',
height: 'var(--line-height-sm)',
borderBottom: '1px solid var(--hl-lg)',
padding: 3,
});
const PaneHeader = styled(OriginalPaneHeader)({
'&&': { alignItems: 'stretch' },
});
interface FormProps {
request: WebSocketRequest;
previewMode: string;
initialValue: string;
environmentId: string;
createOrUpdatePayload: (payload: string, mode: string) => Promise<void>;
}
const WebSocketRequestForm: FC<FormProps> = ({
request,
previewMode,
initialValue,
createOrUpdatePayload,
environmentId,
}) => {
const editorRef = useRef<UnconnectedCodeEditor>(null);
useEffect(() => {
let isMounted = true;
if (isMounted) {
editorRef.current?.codeMirror?.setValue(initialValue);
}
return () => {
isMounted = false;
};
}, [initialValue]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const message = editorRef.current?.getValue() || '';
try {
// Render any nunjucks tag in the message
const renderContext = await getRenderContext({ request, environmentId, purpose: RENDER_PURPOSE_SEND });
const renderedMessage = await render(message, renderContext);
window.main.webSocket.event.send({ requestId: request._id, message: renderedMessage });
} catch (err) {
if (err.type === 'render') {
showModal(RequestRenderErrorModal, {
request,
error: err,
});
} else {
showAlert({
title: 'Unexpected Request Failure',
message: (
<div>
<p>The request failed due to an unhandled error:</p>
<code className="wide selectable">
<pre>{err.message}</pre>
</code>
</div>
),
});
}
}
};
// TODO(@dmarby): Wrap the CodeEditor in a NunjucksEnabledProvider here?
// To allow for disabling rendering of messages based on a per-request setting.
// Same as with regular requests
return (
<SendMessageForm id="websocketMessageForm" onSubmit={handleSubmit}>
<EditorWrapper>
<CodeEditor
uniquenessKey={request._id}
mode={previewMode}
ref={editorRef}
defaultValue=''
onChange={value => createOrUpdatePayload(value, previewMode)}
enableNunjucks
/>
</EditorWrapper>
</SendMessageForm>
);
};
interface Props {
request: WebSocketRequest;
workspaceId: string;
environmentId: string;
forceRefreshKey: number;
}
// requestId is something we can read from the router params in the future.
// essentially we can lift up the states and merge request pane and response pane into a single page and divide the UI there.
// currently this is blocked by the way page layout divide the panes with dragging functionality
// TODO: @gatzjames discuss above assertion in light of request and settings drills
export const WebSocketRequestPane: FC<Props> = ({ request, workspaceId, environmentId, forceRefreshKey }) => {
const readyState = useWSReadyState(request._id);
const disabled = readyState === ReadyState.OPEN || readyState === ReadyState.CLOSING;
const handleOnChange = (url: string) => {
if (url !== request.url) {
models.webSocketRequest.update(request, { url });
}
};
const [previewMode, setPreviewMode] = useState(CONTENT_TYPE_JSON);
const [initialValue, setInitialValue] = useState('');
useEffect(() => {
let isMounted = true;
const fn = async () => {
const payload = await models.webSocketPayload.getByParentId(request._id);
if (isMounted && payload) {
setInitialValue(payload.value);
setPreviewMode(payload.mode);
}
};
fn();
return () => {
isMounted = false;
};
}, [request._id]);
const changeMode = (mode: string) => {
setPreviewMode(mode);
createOrUpdatePayload(initialValue, mode);
};
const createOrUpdatePayload = async (value: string, mode: string) => {
// @TODO: multiple payloads
const payload = await models.webSocketPayload.getByParentId(request._id);
if (payload) {
await models.webSocketPayload.update(payload, { value, mode });
return;
}
await models.webSocketPayload.create({
parentId: request._id,
value,
mode,
});
};
const uniqueKey = `${forceRefreshKey}::${request._id}`;
return (
<Pane type="request">
<PaneHeader>
<WebSocketActionBar
key={uniqueKey}
request={request}
workspaceId={workspaceId}
environmentId={environmentId}
defaultValue={request.url}
readyState={readyState}
onChange={handleOnChange}
/>
</PaneHeader>
<Tabs className="pane__body theme--pane__body react-tabs">
<TabList>
<Tab tabIndex="-1" >
<WebSocketPreviewModeDropdown previewMode={previewMode} onClick={changeMode} />
</Tab>
<Tab tabIndex="-1">
<AuthDropdown authTypes={supportedAuthTypes} disabled={disabled} />
</Tab>
<Tab tabIndex="-1">
<button>Headers</button>
</Tab>
</TabList>
<TabPanel className="react-tabs__tab-panel">
<PaneSendButton>
<SendButton
type="submit"
form="websocketMessageForm"
disabled={readyState !== ReadyState.OPEN}
>
Send
</SendButton>
</PaneSendButton>
<WebSocketRequestForm
key={uniqueKey}
request={request}
previewMode={previewMode}
initialValue={initialValue}
createOrUpdatePayload={createOrUpdatePayload}
environmentId={environmentId}
/>
</TabPanel>
<TabPanel className="react-tabs__tab-panel">
<AuthWrapper
key={`${uniqueKey}-${request.authentication.type}-auth-header`}
disabled={
readyState === ReadyState.OPEN ||
readyState === ReadyState.CLOSING
}
/>
</TabPanel>
<TabPanel className="react-tabs__tab-panel header-editor">
<RequestHeadersEditor
key={`${uniqueKey}-${readyState}-header-editor`}
request={request}
bulk={false}
isDisabled={readyState === ReadyState.OPEN}
/>
</TabPanel>
</Tabs>
</Pane>
);
};

View File

@@ -0,0 +1,209 @@
import fs from 'fs';
import React, { FC, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import styled from 'styled-components';
import { getSetCookieHeaders } from '../../../common/misc';
import { ResponseTimelineEntry } from '../../../main/network/libcurl-promise';
import { WebSocketEvent } from '../../../main/network/websocket';
import { WebSocketResponse } from '../../../models/websocket-response';
import { useWebSocketConnectionEvents } from '../../context/websocket-client/use-ws-connection-events';
import { selectActiveResponse } from '../../redux/selectors';
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 { SizeTag } from '../tags/size-tag';
import { StatusTag } from '../tags/status-tag';
import { TimeTag } from '../tags/time-tag';
import { ResponseCookiesViewer } from '../viewers/response-cookies-viewer';
import { ResponseErrorViewer } from '../viewers/response-error-viewer';
import { ResponseHeadersViewer } from '../viewers/response-headers-viewer';
import { ResponseTimelineViewer } from '../viewers/response-timeline-viewer';
import { EventLogView } from './event-log-view';
import { EventView } from './event-view';
const PaneHeader = styled(OriginalPaneHeader)({
'&&': { justifyContent: 'unset' },
});
const EventLogTableWrapper = styled.div({
width: '100%',
flex: 1,
overflow: 'hidden',
padding: 'var(--padding-sm)',
boxSizing: 'border-box',
});
const EventViewWrapper = styled.div({
flex: 1,
borderTop: '1px solid var(--hl-md)',
height: '100%',
});
const PaneBodyContent = styled.div({
height: '100%',
width: '100%',
display: 'grid',
gridTemplateRows: 'repeat(auto-fit, minmax(0, 1fr))',
});
export const WebSocketResponsePane: FC<{ requestId: string; handleSetActiveResponse: (requestId: string, activeResponse: WebSocketResponse | null) => void }> =
({
requestId,
handleSetActiveResponse,
}) => {
const response = useSelector(selectActiveResponse) as WebSocketResponse | null;
if (!response) {
return (
<Pane type="response">
<PaneHeader />
<EmptyStatePane
icon={<i className="fa fa-paper-plane" />}
documentationLinks={[
{
title: 'Introduction to Insomnia',
url: 'https://docs.insomnia.rest/insomnia/get-started',
},
]}
title="Enter a URL and connect to a WebSocket server to start sending data"
secondaryAction="Select a payload type from above to send data to the connection"
/>
</Pane>
);
}
return <WebSocketActiveResponsePane requestId={requestId} response={response} handleSetActiveResponse={handleSetActiveResponse} />;
};
const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketResponse; handleSetActiveResponse: (requestId: string, activeResponse: WebSocketResponse | null) => void }> = ({
requestId,
response,
handleSetActiveResponse,
}) => {
const [selectedEvent, setSelectedEvent] = useState<WebSocketEvent | null>(null);
const [timeline, setTimeline] = useState<ResponseTimelineEntry[]>([]);
const events = useWebSocketConnectionEvents({ responseId: response._id });
const handleSelection = (event: WebSocketEvent) => {
setSelectedEvent((selected: WebSocketEvent | null) => selected?._id === event._id ? null : event);
};
const setActiveResponseAndDisconnect = (requestId: string, response: WebSocketResponse | null) => {
handleSetActiveResponse(requestId, response);
window.main.webSocket.close({ requestId });
};
useEffect(() => {
setSelectedEvent(null);
}, [response._id]);
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);
};
fn();
return () => {
isMounted = false;
};
}, [response.timelinePath, events.length]);
const cookieHeaders = getSetCookieHeaders(response.headers);
return (
<Pane type="response">
<PaneHeader className="row-spaced">
<div className="no-wrap scrollable scrollable--no-bars pad-left">
<StatusTag statusCode={response.statusCode} statusMessage={response.statusMessage} />
<TimeTag milliseconds={response.elapsedTime} />
<SizeTag bytesRead={0} bytesContent={0} />
</div>
<ResponseHistoryDropdown
activeResponse={response}
handleSetActiveResponse={setActiveResponseAndDisconnect}
requestId={requestId}
className="tall pane__header__right"
/>
</PaneHeader>
<Tabs className="pane__body theme--pane__body react-tabs">
<TabList>
<Tab tabIndex="-1" >
<button>Events</button>
</Tab>
<Tab tabIndex="-1">
<button>
Headers{' '}
{response?.headers.length > 0 && (
<span className="bubble">{response.headers.length}</span>
)}
</button>
</Tab>
<Tab tabIndex="-1">
<button>
Cookies{' '}
{cookieHeaders.length ? (
<span className="bubble">{cookieHeaders.length}</span>
) : null}
</button>
</Tab>
<Tab tabIndex="-1" >
<button>Timeline</button>
</Tab>
</TabList>
<TabPanel className="react-tabs__tab-panel">
<PaneBodyContent>
{response.error ? <ResponseErrorViewer url={response.url} error={response.error} />
: <>
{Boolean(events?.length) && (
<EventLogTableWrapper>
<EventLogView
events={events}
onSelect={handleSelection}
selectionId={selectedEvent?._id}
/>
</EventLogTableWrapper>
)}
{selectedEvent && (
<EventViewWrapper>
<EventView
requestId={requestId}
event={selectedEvent}
/>
</EventViewWrapper>
)}
</>}
</PaneBodyContent>
</TabPanel>
<TabPanel className="react-tabs__tab-panel scrollable-container">
<div className="scrollable pad">
<ErrorBoundary key={response._id} errorClassName="font-error pad text-center">
<ResponseHeadersViewer headers={response.headers} />
</ErrorBoundary>
</div>
</TabPanel>
<TabPanel className="react-tabs__tab-panel scrollable-container">
<div className="scrollable pad">
<ErrorBoundary key={response._id} errorClassName="font-error pad text-center">
<ResponseCookiesViewer
// @TODO: Implement cookie storing and sending
cookiesSent={false}
cookiesStored={false}
headers={cookieHeaders}
/>
</ErrorBoundary>
</div>
</TabPanel>
<TabPanel className="react-tabs__tab-panel">
<ResponseTimelineViewer
key={response._id}
timeline={timeline}
/>
</TabPanel>
</Tabs>
</ Pane>
);
};

View File

@@ -1,10 +1,12 @@
import React, { FC, Fragment, ReactNode } from 'react';
import React, { FC, Fragment, ReactNode, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { isGrpcRequest } from '../../models/grpc-request';
import { isRemoteProject } from '../../models/project';
import { Request, RequestHeader } from '../../models/request';
import type { Response } from '../../models/response';
import { isWebSocketRequest } from '../../models/websocket-request';
import { WebSocketResponse } from '../../models/websocket-response';
import { isCollection, isDesign } from '../../models/workspace';
import { VCS } from '../../sync/vcs/vcs';
import {
@@ -27,6 +29,8 @@ import { RequestPane } from './panes/request-pane';
import { ResponsePane } from './panes/response-pane';
import { SidebarChildren } from './sidebar/sidebar-children';
import { SidebarFilter } from './sidebar/sidebar-filter';
import { WebSocketRequestPane } from './websockets/websocket-request-pane';
import { WebSocketResponsePane } from './websockets/websocket-response-pane';
import { WorkspacePageHeader } from './workspace-page-header';
import type { HandleActivityChange } from './wrapper';
@@ -35,7 +39,7 @@ interface Props {
gitSyncDropdown: ReactNode;
handleActivityChange: HandleActivityChange;
handleSetActiveEnvironment: (id: string | null) => void;
handleSetActiveResponse: (requestId: string, activeResponse: Response | null) => void;
handleSetActiveResponse: (requestId: string, activeResponse: Response | WebSocketResponse | null) => void;
handleForceUpdateRequest: (r: Request, patch: Partial<Request>) => Promise<Request>;
handleForceUpdateRequestHeaders: (r: Request, headers: RequestHeader[]) => Promise<Request>;
handleImport: Function;
@@ -60,20 +64,24 @@ export const WrapperDebug: FC<Props> = ({
headerEditorKey,
vcs,
}) => {
const activeProject = useSelector(selectActiveProject);
const isLoggedIn = useSelector(selectIsLoggedIn);
const activeEnvironment = useSelector(selectActiveEnvironment);
const activeRequest = useSelector(selectActiveRequest);
const activeWorkspace = useSelector(selectActiveWorkspace);
const settings = useSelector(selectSettings);
const sidebarFilter = useSelector(selectSidebarFilter);
const isTeamSync = isLoggedIn && activeWorkspace && isCollection(activeWorkspace) && isRemoteProject(activeProject) && vcs;
// Close all websocket connections when the active environment changes
useEffect(() => {
return () => {
window.main.webSocket.closeAll();
};
}, [activeEnvironment?._id]);
return (
<PageLayout
renderPageHeader={activeWorkspace ?
@@ -114,42 +122,65 @@ export const WrapperDebug: FC<Props> = ({
: null}
renderPaneOne={activeWorkspace ?
<ErrorBoundary showAlert>
{activeRequest && isGrpcRequest(activeRequest) ?
<GrpcRequestPane
activeRequest={activeRequest}
environmentId={activeEnvironment ? activeEnvironment._id : ''}
workspaceId={activeWorkspace._id}
forceRefreshKey={forceRefreshKey}
settings={settings}
/>
:
<RequestPane
environmentId={activeEnvironment ? activeEnvironment._id : ''}
forceRefreshCounter={forceRefreshKey}
forceUpdateRequest={handleForceUpdateRequest}
forceUpdateRequestHeaders={handleForceUpdateRequestHeaders}
handleImport={handleImport}
headerEditorKey={headerEditorKey}
request={activeRequest}
settings={settings}
updateRequestMimeType={handleUpdateRequestMimeType}
workspace={activeWorkspace}
/>}
{activeRequest && (
isGrpcRequest(activeRequest) ? (
<GrpcRequestPane
activeRequest={activeRequest}
environmentId={activeEnvironment ? activeEnvironment._id : ''}
workspaceId={activeWorkspace._id}
forceRefreshKey={forceRefreshKey}
settings={settings}
/>
) : (
isWebSocketRequest(activeRequest) ? (
<WebSocketRequestPane
key={activeRequest._id}
request={activeRequest}
workspaceId={activeWorkspace._id}
environmentId={activeEnvironment ? activeEnvironment._id : ''}
forceRefreshKey={forceRefreshKey}
/>
) : (
<RequestPane
environmentId={activeEnvironment ? activeEnvironment._id : ''}
forceRefreshCounter={forceRefreshKey}
forceUpdateRequest={handleForceUpdateRequest}
forceUpdateRequestHeaders={handleForceUpdateRequestHeaders}
handleImport={handleImport}
headerEditorKey={headerEditorKey}
request={activeRequest}
settings={settings}
updateRequestMimeType={handleUpdateRequestMimeType}
workspace={activeWorkspace}
/>
)
)
)}
</ErrorBoundary>
: null}
renderPaneTwo={
<ErrorBoundary showAlert>
{activeRequest && isGrpcRequest(activeRequest) ?
<GrpcResponsePane
activeRequest={activeRequest}
forceRefreshKey={forceRefreshKey}
/>
:
<ResponsePane
handleSetFilter={handleSetResponseFilter}
request={activeRequest}
handleSetActiveResponse={handleSetActiveResponse}
/>}
{activeRequest && (
isGrpcRequest(activeRequest) ? (
<GrpcResponsePane
activeRequest={activeRequest}
forceRefreshKey={forceRefreshKey}
/>
) : (
isWebSocketRequest(activeRequest) ? (
<WebSocketResponsePane
requestId={activeRequest._id}
handleSetActiveResponse={handleSetActiveResponse}
/>
) : (
<ResponsePane
handleSetFilter={handleSetResponseFilter}
request={activeRequest}
handleSetActiveResponse={handleSetActiveResponse}
/>
)
)
)}
</ErrorBoundary>}
/>
);

View File

@@ -23,6 +23,7 @@ import {
RequestHeader,
} from '../../models/request';
import { Response } from '../../models/response';
import { WebSocketResponse } from '../../models/websocket-response';
import { GitVCS } from '../../sync/git/git-vcs';
import { VCS } from '../../sync/vcs/vcs';
import { CookieModifyModal } from '../components/modals/cookie-modify-modal';
@@ -185,7 +186,8 @@ export class WrapperClass extends PureComponent<Props, State> {
return null;
}
async handleSetActiveResponse(requestId: string, activeResponse: Response | null = null) {
async handleSetActiveResponse(requestId: string, activeResponse: Response | WebSocketResponse | null = null) {
const { activeEnvironment } = this.props;
const activeResponseId = activeResponse ? activeResponse._id : null;
await updateRequestMetaByParentId(requestId, {
@@ -388,7 +390,7 @@ export class WrapperClass extends PureComponent<Props, State> {
/>
<SettingsModal ref={instance => registerModal(instance, 'SettingsModal')} />
<ResponseDebugModal ref={registerModal} />
<ResponseDebugModal ref={instance => registerModal(instance, 'ResponseDebugModal')} />
<RequestSwitcherModal
ref={registerModal}

View File

@@ -29,6 +29,7 @@ import { isNotDefaultProject } from '../../models/project';
import { Request, updateMimeType } from '../../models/request';
import { type RequestGroupMeta } from '../../models/request-group-meta';
import { getByParentId as getRequestMetaByParentId } from '../../models/request-meta';
import { isWebSocketRequest, WebSocketRequest } from '../../models/websocket-request';
import { isWorkspace } from '../../models/workspace';
import * as plugins from '../../plugins';
import * as themes from '../../plugins/misc';
@@ -266,7 +267,7 @@ class App extends PureComponent<AppProps, State> {
];
}
_requestDuplicate(request?: Request | GrpcRequest) {
_requestDuplicate(request?: Request | GrpcRequest | WebSocketRequest) {
if (!request) {
return;
}
@@ -353,6 +354,11 @@ class App extends PureComponent<AppProps, State> {
return null;
}
if (isWebSocketRequest(this.props.activeRequest)) {
console.warn('Tried to update request mime-type on WebSocket request');
return null;
}
const requestMeta = await models.requestMeta.getOrCreateByParentId(
this.props.activeRequest._id,
);

View File

@@ -0,0 +1,25 @@
import { useState } from 'react';
import { useInterval } from 'react-use';
import { WebSocketEvent } from '../../../main/network/websocket';
export function useWebSocketConnectionEvents({ responseId }: { responseId: string }) {
const [events, setEvents] = useState<WebSocketEvent[]>([]);
useInterval(
() => {
let isMounted = true;
const fn = async () => {
const allEvents = await window.main.webSocket.event.findMany({ responseId });
if (isMounted) {
setEvents(allEvents);
}
};
fn();
return () => {
isMounted = false;
};
},
500
);
return events;
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
export enum ReadyState {
CONNECTING = 0,
OPEN = 1,
CLOSING = 2,
CLOSED = 3,
}
export function useWSReadyState(requestId: string): ReadyState {
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.CLOSED);
useEffect(() => {
window.main.webSocket.readyState.getCurrent({ requestId })
.then((currentReadyState: ReadyState) => {
setReadyState(currentReadyState);
});
}, [requestId]);
useEffect(() => {
const unsubscribe = window.main.on(`webSocket.${requestId}.readyState`,
(_, incomingReadyState: ReadyState) => {
setReadyState(incomingReadyState);
});
return unsubscribe;
}, [requestId]);
return readyState;
}

View File

@@ -50,7 +50,7 @@ export const setActiveRequest = async (
});
};
export type CreateRequestType = 'HTTP' | 'gRPC' | 'GraphQL';
export type CreateRequestType = 'HTTP' | 'gRPC' | 'GraphQL' | 'WebSocket';
type RequestCreator = (input: {
parentId: string;
requestType: CreateRequestType;
@@ -110,6 +110,16 @@ export const createRequest: RequestCreator = async ({
break;
}
case 'WebSocket': {
const request = await models.webSocketRequest.create({
parentId,
name: 'New WebSocket Request',
});
models.stats.incrementCreatedRequests();
setActiveRequest(request._id, workspaceId);
break;
}
default:
unreachableCase(
requestType,

View File

@@ -1,30 +1,24 @@
import { useCallback } from 'react';
import { useSelector } from 'react-redux';
import * as models from '../../models';
import { isGrpcRequest } from '../../models/grpc-request';
import { Request } from '../../models/request';
import * as requestOperations from '../../models/helpers/request-operations';
import { Request, RequestAuthentication } from '../../models/request';
import { WebSocketRequest } from '../../models/websocket-request';
import { selectActiveRequest } from '../redux/selectors';
export const useActiveRequest = () => {
const activeRequest = useSelector(selectActiveRequest);
if (!activeRequest) {
throw new Error('Tried to load null request');
if (!activeRequest || !('authentication' in activeRequest)) {
throw new Error('Tried to load invalid request type');
}
if (isGrpcRequest(activeRequest)) {
throw new Error('Loaded GrpcRequest, expected to load Request');
}
const patchRequest = useCallback(async (patch: Partial<Request>) => {
await models.request.update(activeRequest, patch);
const updateAuth = useCallback((authentication: RequestAuthentication) => {
requestOperations.update(activeRequest, { authentication });
}, [activeRequest]);
const updateAuth = useCallback((authentication: Request['authentication']) => patchRequest({ authentication }), [patchRequest]);
const { authentication } = activeRequest;
const patchAuth = useCallback((patch: Partial<Request['authentication']>) => updateAuth({ ...authentication, ...patch }), [authentication, updateAuth]);
const patchAuth = useCallback((patch: Partial<Request['authentication'] | WebSocketRequest['authentication']>) => updateAuth({ ...authentication, ...patch }), [authentication, updateAuth]);
return {
activeRequest,

View File

@@ -17,7 +17,7 @@ const grpcRequestModelBuilder = createBuilder(grpcRequestModelSchema);
describe('shouldShowInSidebar', () => {
const allTypes = models.types();
const supported = [models.request.type, models.requestGroup.type, models.grpcRequest.type];
const supported = [models.request.type, models.requestGroup.type, models.grpcRequest.type, models.webSocketRequest.type];
const unsupported = difference(allTypes, supported);
it.each(supported)('should show %s in sidebar', type => {

View File

@@ -27,6 +27,9 @@ import { Stats } from '../../../models/stats';
import { UnitTest } from '../../../models/unit-test';
import { UnitTestResult } from '../../../models/unit-test-result';
import { UnitTestSuite } from '../../../models/unit-test-suite';
import { WebSocketPayload } from '../../../models/websocket-payload';
import { WebSocketRequest } from '../../../models/websocket-request';
import { WebSocketResponse } from '../../../models/websocket-response';
import { Workspace } from '../../../models/workspace';
import { WorkspaceMeta } from '../../../models/workspace-meta';
@@ -70,6 +73,9 @@ export interface EntitiesState {
protoDirectories: EntityRecord<ProtoDirectory>;
grpcRequests: EntityRecord<GrpcRequest>;
grpcRequestMetas: EntityRecord<GrpcRequestMeta>;
webSocketPayloads: EntityRecord<WebSocketPayload>;
webSocketRequests: EntityRecord<WebSocketRequest>;
webSocketResponses: EntityRecord<WebSocketResponse>;
}
export const initialEntitiesState: EntitiesState = {
@@ -98,6 +104,9 @@ export const initialEntitiesState: EntitiesState = {
protoDirectories: {},
grpcRequests: {},
grpcRequestMetas: {},
webSocketPayloads: {},
webSocketRequests: {},
webSocketResponses: {},
};
export function reducer(state = initialEntitiesState, action: any) {
@@ -108,6 +117,9 @@ export function reducer(state = initialEntitiesState, action: any) {
for (const doc of docs) {
const referenceName = getReducerName(doc.type);
if (!(freshState as any)[referenceName]) {
(freshState as any)[referenceName] = {};
}
(freshState as any)[referenceName][doc._id] = doc;
}
@@ -194,5 +206,8 @@ export async function allDocs() {
...(await models.protoDirectory.all()),
...(await models.grpcRequest.all()),
...(await models.grpcRequestMeta.all()),
...(await models.webSocketPayload.all()),
...(await models.webSocketRequest.all()),
...(await models.webSocketResponse.all()),
];
}

View File

@@ -26,6 +26,7 @@ import { GrpcRequest } from '../../../models/grpc-request';
import * as requestOperations from '../../../models/helpers/request-operations';
import { DEFAULT_PROJECT_ID } from '../../../models/project';
import { Request } from '../../../models/request';
import { WebSocketRequest } from '../../../models/websocket-request';
import { isWorkspace } from '../../../models/workspace';
import { reloadPlugins } from '../../../plugins';
import { createPlugin } from '../../../plugins/create';
@@ -555,7 +556,7 @@ export const exportRequestsToFile = (requestIds: string[]) => async (dispatch: D
dispatch(loadStop());
},
onDone: async selectedFormat => {
const requests: (GrpcRequest | Request)[] = [];
const requests: (GrpcRequest | Request | WebSocketRequest)[] = [];
const privateEnvironments: Environment[] = [];
const workspaceLookup: any = {};

View File

@@ -10,7 +10,10 @@ import { sortProjects } from '../../models/helpers/project';
import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project';
import { isRequest, Request } from '../../models/request';
import { isRequestGroup, RequestGroup } from '../../models/request-group';
import { type Response } from '../../models/response';
import { UnitTestResult } from '../../models/unit-test-result';
import { isWebSocketRequest, WebSocketRequest } from '../../models/websocket-request';
import { type WebSocketResponse } from '../../models/websocket-response';
import { isCollection } from '../../models/workspace';
import { RootState } from './modules';
@@ -311,7 +314,7 @@ export const selectActiveWorkspaceEntities = createSelector(
export const selectPinnedRequests = createSelector(selectEntitiesLists, entities => {
const pinned: Record<string, boolean> = {};
const requests = [...entities.requests, ...entities.grpcRequests];
const requests = [...entities.requests, ...entities.grpcRequests, ...entities.webSocketRequests];
const requestMetas = [...entities.requestMetas, ...entities.grpcRequestMetas];
// Default all to unpinned
@@ -331,8 +334,8 @@ export const selectWorkspaceRequestsAndRequestGroups = createSelector(
selectActiveWorkspaceEntities,
entities => {
return entities.filter(
entity => isRequest(entity) || isGrpcRequest(entity) || isRequestGroup(entity),
) as (Request | GrpcRequest | RequestGroup)[];
entity => isRequest(entity) || isWebSocketRequest(entity) || isGrpcRequest(entity) || isRequestGroup(entity),
) as (Request | WebSocketRequest | GrpcRequest | RequestGroup)[];
},
);
@@ -341,13 +344,20 @@ export const selectActiveRequest = createSelector(
selectActiveWorkspaceMeta,
(entities, workspaceMeta) => {
const id = workspaceMeta?.activeRequestId || 'n/a';
if (id in entities.requests) {
return entities.requests[id];
} else if (id in entities.grpcRequests) {
return entities.grpcRequests[id];
} else {
return null;
}
if (id in entities.grpcRequests) {
return entities.grpcRequests[id];
}
if (id in entities.webSocketRequests) {
return entities.webSocketRequests[id];
}
return null;
},
);
@@ -431,19 +441,21 @@ export const selectActiveRequestResponses = createSelector(
selectSettings,
(activeRequest, entities, activeEnvironment, settings) => {
const requestId = activeRequest ? activeRequest._id : 'n/a';
// Filter responses down if the setting is enabled
return entities.responses
.filter(response => {
const requestMatches = requestId === response.parentId;
if (settings.filterResponsesByEnv) {
const activeEnvironmentId = activeEnvironment ? activeEnvironment._id : null;
const environmentMatches = response.environmentId === activeEnvironmentId;
return requestMatches && environmentMatches;
} else {
return requestMatches;
}
})
const responses: (Response | WebSocketResponse)[] = (activeRequest && isWebSocketRequest(activeRequest)) ? entities.webSocketResponses : entities.responses;
// Filter responses down if the setting is enabled
return responses.filter(response => {
const requestMatches = requestId === response.parentId;
if (settings.filterResponsesByEnv) {
const activeEnvironmentId = activeEnvironment ? activeEnvironment._id : null;
const environmentMatches = response.environmentId === activeEnvironmentId;
return requestMatches && environmentMatches;
} else {
return requestMatches;
}
})
.sort((a, b) => (a.created > b.created ? -1 : 1));
},
);
@@ -453,6 +465,7 @@ export const selectActiveResponse = createSelector(
selectActiveRequestResponses,
(activeRequestMeta, responses) => {
const activeResponseId = activeRequestMeta ? activeRequestMeta.activeResponseId : 'n/a';
const activeResponse = responses.find(response => response._id === activeResponseId);
if (activeResponse) {

View File

@@ -6,6 +6,7 @@ import type { BaseModel } from '../../models';
import { GrpcRequest, isGrpcRequest } from '../../models/grpc-request';
import { isRequest, Request } from '../../models/request';
import { isRequestGroup, RequestGroup } from '../../models/request-group';
import { isWebSocketRequest } from '../../models/websocket-request';
import {
selectActiveWorkspace,
selectActiveWorkspaceMeta,
@@ -17,10 +18,10 @@ import {
type SidebarModel = Request | GrpcRequest | RequestGroup;
export const shouldShowInSidebar = (model: BaseModel): boolean =>
isRequest(model) || isGrpcRequest(model) || isRequestGroup(model);
isRequest(model) || isWebSocketRequest(model) || isGrpcRequest(model) || isRequestGroup(model);
export const shouldIgnoreChildrenOf = (model: SidebarModel): boolean =>
isRequest(model) || isGrpcRequest(model);
isRequest(model) || isWebSocketRequest(model) || isGrpcRequest(model);
export const sortByMetaKeyOrId = (a: SidebarModel, b: SidebarModel): number => {
if (a.metaSortKey === b.metaSortKey) {