Skip to content

Commit 3db2fc5

Browse files
committed
Add WebSocket transform perspective support
1 parent df761da commit 3db2fc5

File tree

11 files changed

+209
-41
lines changed

11 files changed

+209
-41
lines changed

src/components/view/http/http-details-pane.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export class HttpDetailsPane extends React.Component<{
227227
} else if (
228228
expandedCard === 'webSocketMessages' &&
229229
exchange.isWebSocket() &&
230-
exchange.wasAccepted()
230+
exchange.wasAccepted
231231
) {
232232
return this.renderWebSocketMessages(exchange);
233233
} else {
@@ -363,7 +363,7 @@ export class HttpDetailsPane extends React.Component<{
363363
}
364364

365365
if (exchange.isWebSocket()) {
366-
if (exchange.wasAccepted()) {
366+
if (exchange.wasAccepted) {
367367
cards.push(this.renderWebSocketMessages(exchange));
368368

369369
if (exchange.closeState) {

src/components/view/http/http-request-card.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import { observer } from 'mobx-react';
33

4-
import { HttpExchange, HtkRequest, HttpVersion, HttpExchangeView } from '../../../types';
4+
import { HtkRequest, HttpVersion, HttpExchangeView } from '../../../types';
55

66
import { getSummaryColor } from '../../../model/events/categorization';
77
import { getMethodDocs } from '../../../model/http/http-docs';
@@ -29,7 +29,7 @@ import { HttpVersionPill } from '../../common/http-version-pill';
2929
import { HeaderDetails } from './header-details';
3030
import { UrlBreakdown } from '../url-breakdown';
3131
import { HandlerClassKey } from '../../../model/rules/rules';
32-
import { MatchedRulePill } from './matched-rule-pill';
32+
import { MatchedRulePill, shouldShowRuleDetails } from './matched-rule-pill';
3333

3434
const RawRequestDetails = (p: {
3535
request: HtkRequest,
@@ -104,14 +104,9 @@ export const HttpRequestCard = observer((props: HttpRequestCardProps) => {
104104
const { exchange, matchedRuleData, onRuleClicked } = props;
105105
const { request } = exchange;
106106

107-
// We consider passthrough as a no-op, and so don't show anything in that case.
108-
const noopRule = matchedRuleData?.stepTypes.every(
109-
type => type === 'passthrough' || type === 'ws-passthrough'
110-
)
111-
112107
return <CollapsibleCard {...props} direction='right'>
113108
<header>
114-
{ matchedRuleData && !noopRule &&
109+
{ shouldShowRuleDetails(matchedRuleData) &&
115110
<MatchedRulePill
116111
ruleData={matchedRuleData}
117112
onClick={onRuleClicked}

src/components/view/http/matched-rule-pill.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,25 @@ import { getSummaryColor } from '../../../model/events/categorization';
1212
import { aOrAn, uppercaseFirst } from '../../../util/text';
1313
import { PillButton } from '../../common/pill';
1414

15+
export interface MatchedRuleData {
16+
stepTypes: HandlerClassKey[];
17+
status: 'unchanged' | 'modified-types' | 'deleted';
18+
}
19+
20+
export const shouldShowRuleDetails = (
21+
matchedRuleData: MatchedRuleData | undefined
22+
): matchedRuleData is MatchedRuleData => {
23+
// We never bother showing rule details for pure-passthrough rules
24+
return !!matchedRuleData?.stepTypes.length &&
25+
!matchedRuleData?.stepTypes.every(
26+
type => type === 'passthrough' || type === 'ws-passthrough'
27+
);
28+
}
29+
1530
export const MatchedRulePill = styled(inject('uiStore')((p: {
1631
className?: string,
1732
uiStore?: UiStore,
18-
ruleData: {
19-
stepTypes: HandlerClassKey[],
20-
status: 'unchanged' | 'modified-types' | 'deleted'
21-
},
33+
ruleData: MatchedRuleData,
2234
onClick: () => void
2335
}) => {
2436
const { stepTypes } = p.ruleData;

src/components/view/http/transform-card.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import { observer } from 'mobx-react-lite';
55
import { styled } from '../../../styles';
66

77
import { ContentPerspective, UiStore } from '../../../model/ui/ui-store';
8-
import { HandlerClassKey } from '../../../model/rules/rules';
98

109
import { PillSelect } from '../../common/pill';
1110
import { MediumCard } from '../../common/card';
12-
import { MatchedRulePill } from './matched-rule-pill';
11+
import { MatchedRuleData, MatchedRulePill, shouldShowRuleDetails } from './matched-rule-pill';
1312

1413
const DropdownContainer = styled.div`
1514
display: inline-block;
@@ -41,20 +40,11 @@ const PerspectiveSelector = observer((p: {
4140
});
4241

4342
export const TransformCard = (p: {
44-
matchedRuleData?: {
45-
stepTypes: HandlerClassKey[],
46-
status: 'unchanged' | 'modified-types' | 'deleted'
47-
} | undefined,
43+
matchedRuleData?: MatchedRuleData | undefined,
4844
onRuleClicked: () => void,
4945
uiStore: UiStore
5046
}) => {
51-
// We consider passthrough as a no-op, and so don't show anything in that case.
52-
const noopRule = p.matchedRuleData?.stepTypes.every(
53-
type => type === 'passthrough' || type === 'ws-passthrough'
54-
);
55-
56-
// // Show nothing when there's no rule data available or the traffic was no-op'd
57-
if (noopRule || !p.matchedRuleData) return null;
47+
if (!shouldShowRuleDetails(p.matchedRuleData)) return null;
5848

5949
return <MediumCard>
6050
<MatchedRulePill

src/components/view/view-page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ class ViewPage extends React.Component<ViewPageProps> {
294294
) ||
295295
(
296296
expandedViewCard === 'webSocketMessages' &&
297-
!(selectedHttpExchange.isWebSocket() && selectedHttpExchange.wasAccepted())
297+
!(selectedHttpExchange.isWebSocket() && selectedHttpExchange.wasAccepted)
298298
)
299299
) {
300300
runInAction(() => {

src/model/events/events-store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,12 @@ export class EventsStore {
520520
case 'passthrough-abort':
521521
exchange.updateFromUpstreamAbort(event.eventData);
522522
break;
523+
524+
case 'passthrough-websocket-connect':
525+
if (!exchange.isWebSocket()) throw new Error('Received WS connect event for non-WS');
526+
const webSocket = exchange as WebSocketStream;
527+
webSocket.updateWithUpstreamConnect(event.eventData);
528+
break;
523529
}
524530
}
525531

src/model/http/http-exchange.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export class HttpExchange extends HTKEventBase implements HttpExchangeView {
117117

118118
constructor(
119119
request: InputRequest,
120-
private readonly apiStore: ApiStore
120+
protected readonly apiStore: ApiStore
121121
) {
122122
super();
123123

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ApiStore } from "../api/api-store";
2+
import { StreamMessage } from "../events/stream-message";
3+
4+
import { UpstreamHttpExchange } from "../http/upstream-exchange";
5+
import { WebSocketStream } from "./websocket-stream";
6+
import { WebSocketView } from "./websocket-views";
7+
8+
/**
9+
* This represents the upstream side of a proxied WebSocket connection. In the websocket
10+
* case, at the time of writing, the only modifications made during proxying are redirection
11+
* so this really just proxies through to the downstream side except for the initial
12+
* upstream connection parameters.
13+
*
14+
* By and large this is a minimal PoC & structure for future development - there's very little
15+
* real usage of this at the moment until we have more WebSocket transformations available.
16+
*/
17+
export class UpstreamWebSocket extends UpstreamHttpExchange implements WebSocketView {
18+
19+
constructor(downstream: WebSocketStream, apiStore: ApiStore) {
20+
super(downstream, apiStore);
21+
}
22+
23+
isWebSocket() {
24+
return true;
25+
}
26+
27+
declare public readonly downstream: WebSocketStream;
28+
29+
get upstream(): UpstreamWebSocket {
30+
return this;
31+
}
32+
33+
get original(): WebSocketView {
34+
return this.downstream.original;
35+
}
36+
37+
get transformed(): WebSocketView {
38+
return this.downstream.transformed;
39+
}
40+
41+
get wasAccepted() { return this.downstream.wasAccepted; }
42+
get selectedSubprotocol() { return this.downstream.selectedSubprotocol; }
43+
get messages(): readonly StreamMessage[] { return this.downstream.messages; }
44+
get closeState() { return this.downstream.closeState; }
45+
46+
}

src/model/websockets/websocket-stream.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import * as _ from 'lodash';
2-
import { observable, action } from 'mobx';
2+
import { observable, action, computed } from 'mobx';
33

44
import {
55
InputRequest,
66
InputResponse,
77
InputWebSocketMessage,
88
InputWebSocketClose,
9-
InputFailedRequest
9+
InputFailedRequest,
10+
InputRuleEventDataMap
1011
} from '../../types';
1112

1213
import { ApiStore } from '../api/api-store';
1314
import { StreamMessage } from '../events/stream-message';
1415
import { HttpExchange } from '../http/http-exchange';
1516

17+
import { WebSocketOriginalView, WebSocketTransformedView, WebSocketView } from './websocket-views';
18+
import { UpstreamWebSocket } from './upstream-websocket';
19+
1620
// A websocket stream is an HTTP exchange (the initial setup, or even rejection), but
1721
// may include a series of many ongoing messages and a final websocket close event,
1822
// if the initial websocket connection is successful.
19-
export class WebSocketStream extends HttpExchange {
23+
export class WebSocketStream extends HttpExchange implements WebSocketView {
24+
2025
constructor(request: InputRequest, apiStore: ApiStore) {
2126
super(request, apiStore);
2227
this.searchIndex += '\nwebsocket';
@@ -26,11 +31,43 @@ export class WebSocketStream extends HttpExchange {
2631
return true;
2732
}
2833

34+
declare public upstream: UpstreamWebSocket | undefined;
35+
36+
// These are the same as HttpExchangeViewBase, but need to be copied here (because we're not a _view_,
37+
// we're original, and TS has no proper mixin support).
38+
@computed
39+
get original(): WebSocketView {
40+
if (!this.upstream) return this;
41+
42+
// If the request is original, then upstream data matches original data
43+
// I.e. only possible transform was after all upstream data
44+
if (!this.upstream.wasRequestTransformed) {
45+
return this.upstream;
46+
} else {
47+
return new WebSocketOriginalView(this.downstream, this.apiStore);
48+
}
49+
}
50+
51+
@computed
52+
get transformed(): WebSocketView {
53+
if (!this.upstream) return this;
54+
55+
// If the response is original, then upstream data matches transformed data
56+
// I.e. all transforms happened before any upstream data
57+
if (!this.upstream?.wasResponseTransformed) {
58+
return this.upstream;
59+
} else {
60+
return new WebSocketTransformedView(this.downstream, this.apiStore);
61+
}
62+
}
63+
2964
@observable
3065
private accepted = false;
66+
get wasAccepted() { return this.accepted; }
3167

3268
@observable
3369
private subprotocol: string | undefined;
70+
get selectedSubprotocol() { return this.subprotocol; }
3471

3572
@action
3673
setAccepted(response: InputResponse) {
@@ -41,14 +78,6 @@ export class WebSocketStream extends HttpExchange {
4178
Object.assign(this.timingEvents, response.timingEvents);
4279
}
4380

44-
wasAccepted() {
45-
return this.accepted;
46-
}
47-
48-
get selectedSubprotocol() {
49-
return this.subprotocol;
50-
}
51-
5281
@observable
5382
readonly messages: Array<StreamMessage> = [];
5483

@@ -73,7 +102,7 @@ export class WebSocketStream extends HttpExchange {
73102
}
74103

75104
markAborted(request: InputFailedRequest) {
76-
if (!this.wasAccepted()) {
105+
if (!this.wasAccepted) {
77106
// An abort before accept acts exactly as in normal HTTP
78107
return super.markAborted(request);
79108
} else {
@@ -88,6 +117,16 @@ export class WebSocketStream extends HttpExchange {
88117
}
89118
}
90119

120+
// The assorted normal upstreamFromUpstream... methods will never be called, as the server side
121+
// is completely different, so UpstreamHttpExchange will never be populated by the base class.
122+
123+
updateWithUpstreamConnect(params: InputRuleEventDataMap['passthrough-websocket-connect']) {
124+
if (!this.upstream) {
125+
this.upstream = new UpstreamWebSocket(this, this.apiStore);
126+
}
127+
this.upstream.updateWithRequestHead(params);
128+
}
129+
91130
cleanup() {
92131
super.cleanup();
93132

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { InputWebSocketClose, WebSocketStream } from '../../types';
2+
import { ApiStore } from '../api/api-store';
3+
4+
import { StreamMessage } from '../events/stream-message';
5+
import {
6+
HttpExchangeView,
7+
HttpExchangeOriginalView,
8+
HttpExchangeTransformedView
9+
} from '../http/http-exchange-views';
10+
import { UpstreamWebSocket } from './upstream-websocket';
11+
12+
export interface WebSocketView extends HttpExchangeView {
13+
14+
get downstream(): WebSocketStream;
15+
get upstream(): UpstreamWebSocket | undefined;
16+
17+
get original(): WebSocketView;
18+
get transformed(): WebSocketView;
19+
20+
get wasAccepted(): boolean;
21+
get selectedSubprotocol(): string | undefined;
22+
get messages(): ReadonlyArray<StreamMessage>;
23+
get closeState(): InputWebSocketClose | 'aborted' | undefined;
24+
25+
}
26+
27+
export class WebSocketOriginalView extends HttpExchangeOriginalView implements WebSocketView {
28+
29+
constructor(downstreamWebSocket: WebSocketStream, apiStore: ApiStore) {
30+
super(downstreamWebSocket, apiStore);
31+
}
32+
33+
declare public readonly downstream: WebSocketStream;
34+
35+
isWebSocket() {
36+
return true;
37+
}
38+
39+
get upstream(): UpstreamWebSocket | undefined {
40+
return this.downstream.upstream;
41+
}
42+
43+
get original(): WebSocketView { return this; }
44+
get transformed(): WebSocketView { return this.downstream.transformed; }
45+
46+
get wasAccepted() { return this.downstream.wasAccepted; }
47+
get selectedSubprotocol() { return this.downstream.selectedSubprotocol; }
48+
get messages() { return this.downstream.messages; }
49+
get closeState() { return this.downstream.closeState; }
50+
51+
}
52+
53+
export class WebSocketTransformedView extends HttpExchangeTransformedView implements WebSocketView {
54+
55+
constructor(exchange: WebSocketStream, apiStore: ApiStore) {
56+
super(exchange, apiStore);
57+
}
58+
59+
declare public readonly downstream: WebSocketStream;
60+
61+
isWebSocket() {
62+
return true;
63+
}
64+
65+
get upstream(): UpstreamWebSocket | undefined {
66+
return this.downstream.upstream;
67+
}
68+
69+
get original(): WebSocketView { return this.downstream.original; }
70+
get transformed(): WebSocketView { return this; }
71+
72+
get wasAccepted() { return this.downstream.wasAccepted; }
73+
get selectedSubprotocol() { return this.downstream.selectedSubprotocol; }
74+
get messages() { return this.downstream.messages; }
75+
get closeState() { return this.downstream.closeState; }
76+
77+
}

0 commit comments

Comments
 (0)