aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/App/App.tsx40
-rw-r--r--src/components/Frames/Frames.module.scss9
-rw-r--r--src/components/Frames/Frames.tsx75
-rw-r--r--src/components/Icons/Sliders.tsx2
-rw-r--r--src/components/Icons/Trash.tsx2
-rw-r--r--src/components/RequestDetails/RequestDetails.tsx82
-rw-r--r--src/components/RequestList/RequestList.tsx48
-rw-r--r--src/components/RequestSummary/RequestSummary.tsx2
-rw-r--r--src/contexts/Connection.tsx288
-rw-r--r--src/contexts/DarkMode.tsx7
-rw-r--r--src/hooks/useRequests.tsx253
-rw-r--r--src/index.tsx5
-rw-r--r--src/types.ts136
13 files changed, 774 insertions, 175 deletions
diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx
index b1a4501..ad36add 100644
--- a/src/components/App/App.tsx
+++ b/src/components/App/App.tsx
@@ -1,6 +1,6 @@
1import * as React from "react"; 1import * as React from "react";
2import { ReactElement, useContext, useEffect, useMemo, useState } from "react"; 2import { ReactElement, useContext, useEffect, useMemo, useState } from "react";
3import useRequests, { ReadyState, RequestResponse } from "~/hooks/useRequests"; 3import useRequests from "~/hooks/useRequests";
4 4
5import styles from "~/components/App/App.module.scss"; 5import styles from "~/components/App/App.module.scss";
6import RequestDetails from "~/components/RequestDetails/RequestDetails"; 6import RequestDetails from "~/components/RequestDetails/RequestDetails";
@@ -13,6 +13,8 @@ import Moon from "~/components/Icons/Moon";
13import Trash from "~/components/Icons/Trash"; 13import Trash from "~/components/Icons/Trash";
14import { DarkModeContext } from "~/contexts/DarkMode"; 14import { DarkModeContext } from "~/contexts/DarkMode";
15import RequestList from "~/components/RequestList/RequestList"; 15import RequestList from "~/components/RequestList/RequestList";
16import { Call, ReadyState } from "~/types";
17import { ConnectionContext } from "~/contexts/Connection";
16 18
17interface Config { 19interface Config {
18 url: string; 20 url: string;
@@ -47,29 +49,14 @@ const statusTextMap: ReadyStateMap = {
47 49
48export default function App() { 50export default function App() {
49 const { darkMode, toggle } = useContext(DarkModeContext); 51 const { darkMode, toggle } = useContext(DarkModeContext);
50 const [config, setConfig] = useState<Config | null>(null); 52 const { config, selectedCall, setSelectedCall, readyState, clear } =
51 53 useContext(ConnectionContext);
52 const { calls, readyState, clear } = useRequests({
53 onConnect: async () => {
54 const response = await fetch(`http://${getHost()}/config/`);
55 const config = await response.json();
56 setConfig(config);
57 },
58 });
59 54
60 useEffect(() => { 55 useEffect(() => {
61 const url = new URL(config?.url ?? "https://loading..."); 56 const url = new URL(config?.url ?? "https://loading...");
62 document.title = `${statusIconMap[readyState]} ${url.host} | TTUN`; 57 document.title = `${statusIconMap[readyState]} ${url.host} | TTUN`;
63 }, [readyState, config?.url]); 58 }, [readyState, config?.url]);
64 59
65 const [selectedRequestIndex, setSelectedRequestIndex] = useState<
66 number | null
67 >(null);
68 const selectedRequest = useMemo<RequestResponse | null>(
69 () => (selectedRequestIndex === null ? null : calls[selectedRequestIndex]),
70 [selectedRequestIndex, calls]
71 );
72
73 const settingsMenu: (SettingsMenu | null)[] = [ 60 const settingsMenu: (SettingsMenu | null)[] = [
74 { 61 {
75 onClick: toggle, 62 onClick: toggle,
@@ -79,7 +66,7 @@ export default function App() {
79 null, 66 null,
80 { 67 {
81 onClick: () => { 68 onClick: () => {
82 setSelectedRequestIndex(null); 69 setSelectedCall(null);
83 clear(); 70 clear();
84 }, 71 },
85 icon: <Trash />, 72 icon: <Trash />,
@@ -105,14 +92,15 @@ export default function App() {
105 </a> 92 </a>
106 </Navbar.Text> 93 </Navbar.Text>
107 <Navbar.Toggle aria-controls="settings" /> 94 <Navbar.Toggle aria-controls="settings" />
108 <Navbar.Collapse id="settings" className="ms-2"> 95 <Navbar.Collapse id="settings" className="ms-2" role="button">
109 <Nav> 96 <Nav>
110 <NavDropdown align="end" title={<Sliders />}> 97 <NavDropdown align="end" title={<Sliders />}>
111 {settingsMenu.map((item) => { 98 {settingsMenu.map((item, index) => {
112 if (item !== null) { 99 if (item !== null) {
113 const { onClick, icon, label } = item; 100 const { onClick, icon, label } = item;
114 return ( 101 return (
115 <NavDropdown.Item 102 <NavDropdown.Item
103 key={label}
116 onClick={onClick} 104 onClick={onClick}
117 className="d-flex align-items-center" 105 className="d-flex align-items-center"
118 > 106 >
@@ -121,7 +109,7 @@ export default function App() {
121 </NavDropdown.Item> 109 </NavDropdown.Item>
122 ); 110 );
123 } else { 111 } else {
124 return <NavDropdown.Divider />; 112 return <NavDropdown.Divider key={`item-${index}`} />;
125 } 113 }
126 })} 114 })}
127 </NavDropdown> 115 </NavDropdown>
@@ -133,14 +121,10 @@ export default function App() {
133 121
134 <main className={styles.main}> 122 <main className={styles.main}>
135 <div className={classNames("border-end", styles.sidebar)}> 123 <div className={classNames("border-end", styles.sidebar)}>
136 <RequestList 124 <RequestList />
137 requests={calls}
138 selectedRequestIndex={selectedRequestIndex}
139 setSelectedRequestIndex={setSelectedRequestIndex}
140 />
141 </div> 125 </div>
142 <div className={styles.details}> 126 <div className={styles.details}>
143 <RequestDetails requestResponse={selectedRequest} /> 127 <RequestDetails />
144 </div> 128 </div>
145 </main> 129 </main>
146 </div> 130 </div>
diff --git a/src/components/Frames/Frames.module.scss b/src/components/Frames/Frames.module.scss
new file mode 100644
index 0000000..821f0b8
--- /dev/null
+++ b/src/components/Frames/Frames.module.scss
@@ -0,0 +1,9 @@
1.arrow {
2 &.outbound {
3 color: green;
4 }
5
6 &.inbound {
7 color: red;
8 }
9}
diff --git a/src/components/Frames/Frames.tsx b/src/components/Frames/Frames.tsx
new file mode 100644
index 0000000..83c4c96
--- /dev/null
+++ b/src/components/Frames/Frames.tsx
@@ -0,0 +1,75 @@
1import { Frame } from "~/types";
2import React, { PropsWithChildren, useContext } from "react";
3import { Col, ListGroup, Row } from "react-bootstrap";
4import classNames from "classnames";
5import styles from "./Frames.module.scss";
6import dayjs from "dayjs";
7import ReactJson from "react-json-view";
8import { DarkModeContext } from "~/contexts/DarkMode";
9
10function isJson(data: any): boolean {
11 try {
12 JSON.parse(data);
13 return true;
14 } catch {
15 return false;
16 }
17}
18
19interface FramesProps {
20 frames: Frame[];
21}
22
23export default function Frames({
24 frames,
25}: PropsWithChildren<FramesProps>): JSX.Element {
26 const { darkMode } = useContext(DarkModeContext);
27 return (
28 <ListGroup variant="flush" as="ul" className={"flex-grow-1"}>
29 {frames.length > 0 ? (
30 frames.map((frame) => {
31 // Inbound relative to the client
32 const inbound = frame.type !== "websocket_inbound";
33
34 const body =
35 frame.type !== "websocket_disconnect"
36 ? atob(frame.payload.body)
37 : null;
38
39 return (
40 <ListGroup.Item
41 as="li"
42 key={frame.payload.id}
43 className={classNames({
44 "border-bottom": true,
45 })}
46 >
47 <Row>
48 <Col className="flex-grow-0 d-flex align-items-center text-nowrap text-muted">
49 {dayjs(frame.payload?.timestamp).format("HH:mm:ss.SSS")}
50 </Col>
51 <Col
52 className={classNames(
53 styles.arrow,
54 "flex-grow-0 d-flex align-items-center flex-nowrap",
55 {
56 [styles.inbound]: inbound,
57 [styles.outbound]: !inbound,
58 }
59 )}
60 >
61 {inbound ? "▼" : "▲"}
62 </Col>
63 <Col className="flex-grow-1">{body}</Col>
64 </Row>
65 </ListGroup.Item>
66 );
67 })
68 ) : (
69 <div className={styles.noRequest}>
70 <p>No messages</p>
71 </div>
72 )}
73 </ListGroup>
74 );
75}
diff --git a/src/components/Icons/Sliders.tsx b/src/components/Icons/Sliders.tsx
index 66d6fac..3c9b38b 100644
--- a/src/components/Icons/Sliders.tsx
+++ b/src/components/Icons/Sliders.tsx
@@ -11,7 +11,7 @@ export default function Sliders() {
11 viewBox="0 0 16 16" 11 viewBox="0 0 16 16"
12 > 12 >
13 <path 13 <path
14 fill-rule="evenodd" 14 fillRule="evenodd"
15 d="M11.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM9.05 3a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0V3h9.05zM4.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM2.05 8a2.5 2.5 0 0 1 4.9 0H16v1H6.95a2.5 2.5 0 0 1-4.9 0H0V8h2.05zm9.45 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-2.45 1a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0v-1h9.05z" 15 d="M11.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM9.05 3a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0V3h9.05zM4.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM2.05 8a2.5 2.5 0 0 1 4.9 0H16v1H6.95a2.5 2.5 0 0 1-4.9 0H0V8h2.05zm9.45 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-2.45 1a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0v-1h9.05z"
16 /> 16 />
17 </svg> 17 </svg>
diff --git a/src/components/Icons/Trash.tsx b/src/components/Icons/Trash.tsx
index 6c0039b..cf03546 100644
--- a/src/components/Icons/Trash.tsx
+++ b/src/components/Icons/Trash.tsx
@@ -12,7 +12,7 @@ export default function Trash() {
12 > 12 >
13 <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" /> 13 <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
14 <path 14 <path
15 fill-rule="evenodd" 15 fillRule="evenodd"
16 d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" 16 d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
17 /> 17 />
18 </svg> 18 </svg>
diff --git a/src/components/RequestDetails/RequestDetails.tsx b/src/components/RequestDetails/RequestDetails.tsx
index f34ef6f..22b3fe2 100644
--- a/src/components/RequestDetails/RequestDetails.tsx
+++ b/src/components/RequestDetails/RequestDetails.tsx
@@ -1,11 +1,13 @@
1import * as React from "react"; 1import * as React from "react";
2import { useCallback, useMemo, useState } from "react"; 2import { useCallback, useContext, useMemo, useState } from "react";
3import { Headers, RequestResponse } from "~/hooks/useRequests";
4import styles from "~/components/RequestDetails/RequestDetails.module.scss"; 3import styles from "~/components/RequestDetails/RequestDetails.module.scss";
5import RequestSummary from "~/components/RequestSummary/RequestSummary"; 4import RequestSummary from "~/components/RequestSummary/RequestSummary";
6import Content from "~/components/Content/Content"; 5import Content from "~/components/Content/Content";
7import { getHost } from "~/utils"; 6import { getHost } from "~/utils";
8import { Button, Card, Col, Container, Nav, Row, Table } from "react-bootstrap"; 7import { Button, Card, Col, Container, Nav, Row, Table } from "react-bootstrap";
8import { Call, Headers, selectedCall } from "~/types";
9import { ConnectionContext } from "~/contexts/Connection";
10import Frames from "~/components/Frames/Frames";
9 11
10interface TimingProps { 12interface TimingProps {
11 timing: number; 13 timing: number;
@@ -49,37 +51,35 @@ function HeaderTable({ title, headers }: HeaderTableProps) {
49 ); 51 );
50} 52}
51 53
52interface DetailsProps { 54type Tab = "headers" | "request" | "response" | "messages";
53 requestResponse: RequestResponse | null;
54}
55 55
56type Tab = "headers" | "request" | "response"; 56export default function RequestDetails() {
57 const { selectedCall } = useContext(ConnectionContext);
57 58
58export default function RequestDetails({ requestResponse }: DetailsProps) {
59 const [tab, selectTab] = useState<Tab>("headers"); 59 const [tab, selectTab] = useState<Tab>("headers");
60 const [raw, setRaw] = useState(false); 60 const [raw, setRaw] = useState(false);
61 61
62 const resend = useCallback( 62 const resend = useCallback(
63 async () => 63 async () =>
64 requestResponse !== null && 64 selectedCall !== null &&
65 fetch(`http://${getHost()}/resend/`, { 65 fetch(`http://${getHost()}/resend/`, {
66 method: "POST", 66 method: "POST",
67 body: JSON.stringify({ 67 body: JSON.stringify({
68 ...requestResponse.request, 68 ...selectedCall.request,
69 id: undefined, 69 id: undefined,
70 }), 70 }),
71 }), 71 }),
72 [requestResponse] 72 [selectedCall]
73 ); 73 );
74 74
75 return requestResponse !== null ? ( 75 return selectedCall !== null ? (
76 <div className={styles.details}> 76 <div className={styles.details}>
77 <div className={styles.header}> 77 <div className={styles.header}>
78 <Row> 78 <Row>
79 <Col> 79 <Col>
80 <Container fluid style={{ fontSize: "1.5em" }} className="py-3"> 80 <Container fluid style={{ fontSize: "1.5em" }} className="py-3">
81 <RequestSummary 81 <RequestSummary
82 requestResponse={requestResponse} 82 requestResponse={selectedCall}
83 className={styles.summary} 83 className={styles.summary}
84 /> 84 />
85 </Container> 85 </Container>
@@ -98,18 +98,29 @@ export default function RequestDetails({ requestResponse }: DetailsProps) {
98 <Nav.Item> 98 <Nav.Item>
99 <Nav.Link eventKey="request">Request</Nav.Link> 99 <Nav.Link eventKey="request">Request</Nav.Link>
100 </Nav.Item> 100 </Nav.Item>
101 <Nav.Item> 101 {selectedCall.type === "RequestResponse" && (
102 <Nav.Link 102 <>
103 eventKey="response" 103 <Nav.Item>
104 disabled={requestResponse.response === undefined} 104 <Nav.Link
105 > 105 eventKey="response"
106 Response 106 disabled={selectedCall.response === undefined}
107 </Nav.Link> 107 >
108 </Nav.Item> 108 Response
109 </Nav.Link>
110 </Nav.Item>
111 </>
112 )}
113 {selectedCall.type === "WebsocketConnection" && (
114 <>
115 <Nav.Item>
116 <Nav.Link eventKey="response">Messages</Nav.Link>
117 </Nav.Item>
118 </>
119 )}
109 </Nav> 120 </Nav>
110 </Col> 121 </Col>
111 <Col className="border-bottom px-3 " xs="auto"> 122 <Col className="border-bottom px-3 " xs="auto">
112 <Timing timing={requestResponse.response?.timing ?? NaN} /> 123 <Timing timing={selectedCall.response?.timing ?? NaN} />
113 <Button 124 <Button
114 variant="outline-primary" 125 variant="outline-primary"
115 onClick={() => resend()} 126 onClick={() => resend()}
@@ -125,21 +136,30 @@ export default function RequestDetails({ requestResponse }: DetailsProps) {
125 <> 136 <>
126 <HeaderTable 137 <HeaderTable
127 title="request headers" 138 title="request headers"
128 headers={requestResponse.request.headers} 139 headers={selectedCall.request.headers}
129 /> 140 />
130 {requestResponse.response && ( 141
131 <HeaderTable 142 {selectedCall.type === "RequestResponse" &&
132 title="response headers" 143 selectedCall.response !== undefined && (
133 headers={requestResponse.response.headers} 144 <HeaderTable
134 /> 145 title="response headers"
135 )} 146 headers={selectedCall.response.headers}
147 />
148 )}
136 </> 149 </>
137 )} 150 )}
138 {tab === "request" && ( 151 {tab === "request" && (
139 <Content data={requestResponse.request} raw={raw} setRaw={setRaw} /> 152 <Content data={selectedCall.request} raw={raw} setRaw={setRaw} />
140 )} 153 )}
141 {tab === "response" && requestResponse.response !== undefined && ( 154 {selectedCall.type === "RequestResponse" &&
142 <Content data={requestResponse.response} raw={raw} setRaw={setRaw} /> 155 tab === "response" &&
156 selectedCall.response !== undefined && (
157 <Content data={selectedCall.response} raw={raw} setRaw={setRaw} />
158 )}
159 {selectedCall.type === "WebsocketConnection" && tab === "response" && (
160 <Frames frames={selectedCall.frames} />
161 // <Content data={selectedCall.frames} raw={false} setRaw={setRaw} />
162 // <pre>{JSON.stringify(selectedCall.frames, undefined, 2)}</pre>
143 )} 163 )}
144 </div> 164 </div>
145 </div> 165 </div>
diff --git a/src/components/RequestList/RequestList.tsx b/src/components/RequestList/RequestList.tsx
index acef47a..82a6b21 100644
--- a/src/components/RequestList/RequestList.tsx
+++ b/src/components/RequestList/RequestList.tsx
@@ -11,27 +11,24 @@ import classNames from "classnames";
11import styles from "~/components/RequestList/RequestList.module.scss"; 11import styles from "~/components/RequestList/RequestList.module.scss";
12import RequestSummary from "~/components/RequestSummary/RequestSummary"; 12import RequestSummary from "~/components/RequestSummary/RequestSummary";
13import * as React from "react"; 13import * as React from "react";
14import { useCallback, useContext, useMemo, useState } from "react"; 14import { useCallback, useContext, useEffect, useMemo, useState } from "react";
15import { Method, RequestResponse } from "~/hooks/useRequests";
16import { DarkModeContext } from "~/contexts/DarkMode"; 15import { DarkModeContext } from "~/contexts/DarkMode";
17import Filter from "~/components/Icons/Filter"; 16import Filter from "~/components/Icons/Filter";
18 17import { Call, Method } from "~/types";
19interface ListProps { 18import { ConnectionContext } from "~/contexts/Connection";
20 requests: RequestResponse[];
21 selectedRequestIndex: number | null;
22 setSelectedRequestIndex: (index: number) => void;
23}
24 19
25type EnabledMethods = { 20type EnabledMethods = {
26 [method in Method]: boolean; 21 [method in Method]: boolean;
27}; 22};
28 23
29export default function RequestList({ 24export default function RequestList() {
30 requests,
31 selectedRequestIndex,
32 setSelectedRequestIndex,
33}: ListProps) {
34 const { darkMode } = useContext(DarkModeContext); 25 const { darkMode } = useContext(DarkModeContext);
26 const {
27 calls: requests,
28 selectedCall,
29 setSelectedCall,
30 } = useContext(ConnectionContext);
31
35 const [showFilterOptions, setShowFilterOptions] = useState(false); 32 const [showFilterOptions, setShowFilterOptions] = useState(false);
36 const [search, setSearch] = useState(""); 33 const [search, setSearch] = useState("");
37 const [enableRegex, setEnableRegex] = useState(false); 34 const [enableRegex, setEnableRegex] = useState(false);
@@ -73,7 +70,7 @@ export default function RequestList({
73 () => 70 () =>
74 Object.entries(methods) 71 Object.entries(methods)
75 .filter(([method, enabled]) => enabled) 72 .filter(([method, enabled]) => enabled)
76 .map(([method]) => method), 73 .map(([method]) => method) as Method[],
77 [methods] 74 [methods]
78 ); 75 );
79 76
@@ -97,16 +94,14 @@ export default function RequestList({
97 } catch {} 94 } catch {}
98 95
99 return requests 96 return requests
100 .map<[number, RequestResponse]>((request, index) => [index, request])
101 .reverse() 97 .reverse()
102 .filter( 98 .filter(
103 ([index, request]) => 99 (request) =>
104 enabledMethods.length > 0 === null || 100 enabledMethods.length === 0 ||
105 enabledMethods.includes(request.request.method) 101 enabledMethods.includes(request.request.method)
106 ) 102 )
107 .filter( 103 .filter(
108 ([index, request]) => 104 (request) => search === "" || searchRegex.test(request.request.path)
109 search === "" || searchRegex.test(request.request.path)
110 ); 105 );
111 }, [requests, search, enabledMethods, enableRegex]); 106 }, [requests, search, enabledMethods, enableRegex]);
112 107
@@ -124,7 +119,10 @@ export default function RequestList({
124 </Dropdown.Item> 119 </Dropdown.Item>
125 <Dropdown.Divider /> 120 <Dropdown.Divider />
126 {Object.entries(methods).map(([method, enabled]) => ( 121 {Object.entries(methods).map(([method, enabled]) => (
127 <Dropdown.Item onClick={() => toggleMethods(method as Method)}> 122 <Dropdown.Item
123 onClick={() => toggleMethods(method as Method)}
124 key={method}
125 >
128 {method} 126 {method}
129 </Dropdown.Item> 127 </Dropdown.Item>
130 ))} 128 ))}
@@ -151,13 +149,14 @@ export default function RequestList({
151 className={classNames(styles.list, "flex-grow-1")} 149 className={classNames(styles.list, "flex-grow-1")}
152 > 150 >
153 {filteredRequests.length > 0 ? ( 151 {filteredRequests.length > 0 ? (
154 filteredRequests.map(([index, requestResponse]) => { 152 filteredRequests.map((requestResponse) => {
155 const selected = selectedRequestIndex === index; 153 const selected =
154 selectedCall?.request.id === requestResponse.request.id;
156 return ( 155 return (
157 <ListGroup.Item 156 <ListGroup.Item
158 as="li" 157 as="li"
159 onClick={() => setSelectedRequestIndex(index)} 158 onClick={() => setSelectedCall?.(requestResponse)}
160 key={`request-${index}`} 159 key={requestResponse.request.id}
161 className={classNames({ 160 className={classNames({
162 "bg-primary": selected, 161 "bg-primary": selected,
163 "text-light": selected, 162 "text-light": selected,
@@ -209,6 +208,7 @@ export default function RequestList({
209 <Form.Label className="fw-bold">Method</Form.Label> 208 <Form.Label className="fw-bold">Method</Form.Label>
210 {Object.entries(methods).map(([method, enabled]) => ( 209 {Object.entries(methods).map(([method, enabled]) => (
211 <Form.Check 210 <Form.Check
211 key={method}
212 type="switch" 212 type="switch"
213 label={method} 213 label={method}
214 checked={enabled} 214 checked={enabled}
diff --git a/src/components/RequestSummary/RequestSummary.tsx b/src/components/RequestSummary/RequestSummary.tsx
index 49e6086..4e97676 100644
--- a/src/components/RequestSummary/RequestSummary.tsx
+++ b/src/components/RequestSummary/RequestSummary.tsx
@@ -7,7 +7,7 @@ import dayjs from "dayjs";
7 7
8interface RequestSummaryProps { 8interface RequestSummaryProps {
9 selected?: boolean; 9 selected?: boolean;
10 requestResponse: RequestResponse; 10 requestResponse: Calls;
11 showTime?: boolean; 11 showTime?: boolean;
12} 12}
13 13
diff --git a/src/contexts/Connection.tsx b/src/contexts/Connection.tsx
new file mode 100644
index 0000000..42482e1
--- /dev/null
+++ b/src/contexts/Connection.tsx
@@ -0,0 +1,288 @@
1import * as React from "react";
2
3import {
4 Context,
5 createContext,
6 PropsWithChildren,
7 useCallback,
8 useEffect,
9 useMemo,
10 useState,
11} from "react";
12import {
13 Call,
14 Frame,
15 Frames,
16 Historic,
17 ReadyState,
18 Request,
19 RequestResponseType,
20 Requests,
21 Response,
22 ResponsePayload,
23 Responses,
24 WebsocketConnect,
25 WebsocketConnected,
26 WebsocketConnectedPayload,
27 WebsocketDisconnectPayload,
28 WebsocketInboundPayload,
29 WebsocketOutboundPayload,
30 WebsocketType,
31} from "~/types";
32import { getHost } from "~/utils";
33
34interface Config {
35 url: string;
36}
37
38interface ConnectionApi {
39 config: Config | null;
40 calls: Call[];
41 selectedCall: Call | null;
42 setSelectedCall: (call: Call | null) => void;
43 readyState: ReadyState;
44 clear: () => void;
45}
46
47export const ConnectionContext = createContext<Partial<ConnectionApi>>(
48 {}
49) as Context<ConnectionApi>;
50
51export default function ConnectionProvider({
52 children,
53}: PropsWithChildren<any>): JSX.Element {
54 const [selectedCall, setSelectedCall] = useState<Call | null>(null);
55 const [config, setConfig] = useState<Config | null>(null);
56
57 const wsHost = useMemo(getHost, []);
58
59 const [initialConnection, setInitialConnection] = useState(true);
60 const [requests, setRequests] = useState<Requests>([]);
61 const [responses, setResponses] = useState<Responses>({});
62 const [websocketFrames, setWebsocketFrames] = useState<Frames>({});
63
64 const connect = useCallback(
65 () => new WebSocket(`ws://${wsHost}/inspect/`),
66 [wsHost]
67 );
68
69 const [ws, setWs] = useState<WebSocket>(() => connect());
70 const [readyState, setReadyState] = useState<ReadyState>(ws.readyState);
71
72 useEffect(() => {
73 setReadyState(ws.readyState);
74
75 const onClose = () => {
76 setReadyState(ws.readyState);
77 setWs(connect());
78 };
79
80 const onOpen = async () => {
81 const response = await fetch(`http://${getHost()}/config/`);
82 const config = await response.json();
83 setConfig(config);
84
85 setInitialConnection(false);
86 setReadyState(ws.readyState);
87 };
88
89 const onMessage = ({ data }: { data: string }) => {
90 const { type, payload } = JSON.parse(data) as
91 | Historic
92 | RequestResponseType
93 | WebsocketType;
94
95 switch (type) {
96 case "historic":
97 if (initialConnection) {
98 const requests = (
99 payload as (RequestResponseType | WebsocketType)[]
100 )
101 .filter(
102 ({ type }) => type === "request" || type == "websocket_connect"
103 )
104 .map((payload) => {
105 if (
106 (payload as WebsocketConnect).payload.method === undefined
107 ) {
108 (payload as Request | WebsocketConnect).payload.method =
109 "GET";
110 }
111
112 return payload;
113 });
114 const responses = (
115 payload as (RequestResponseType | WebsocketType)[]
116 )
117 .filter(
118 ({ type }) =>
119 type === "response" || type == "websocket_connected"
120 )
121 .map(({ type, ...item }) => {
122 if (type == "websocket_connected") {
123 (item.payload as WebsocketConnectedPayload).status = 101;
124 }
125
126 return { type, ...item };
127 })
128 .reduce<{ [id: string]: Response | WebsocketConnected }>(
129 (out, item) => ({
130 ...out,
131 [item.payload.id]: item as Response | WebsocketConnected,
132 }),
133 {}
134 );
135 const frames = (payload as (RequestResponseType | WebsocketType)[])
136 .filter(
137 ({ type }) =>
138 type == "websocket_inbound" ||
139 type == "websocket_outbound" ||
140 type == "websocket_disconnect"
141 )
142 .reduce<Frames>((out, item) => {
143 if (!out.hasOwnProperty(item.payload.id)) {
144 out[item.payload.id] = [];
145 }
146
147 out[item.payload.id].push(item as Frame);
148 return out;
149 }, {});
150 setRequests((rqs) => [
151 ...rqs,
152 ...requests.map(
153 (payload) => payload as Request | WebsocketConnect
154 ),
155 ]);
156 setResponses((rps) => ({
157 ...rps,
158 ...responses,
159 }));
160 setWebsocketFrames((frms) => ({
161 ...frms,
162 ...frames,
163 }));
164 }
165 break;
166 case "request":
167 case "websocket_connect":
168 setRequests((rqs) => [
169 ...rqs,
170 {
171 type,
172 payload:
173 type === "request"
174 ? payload
175 : {
176 ...payload,
177 method: "GET",
178 },
179 } as Request | WebsocketConnect,
180 ]);
181 break;
182 case "response":
183 case "websocket_connected":
184 if (type == "websocket_connected") {
185 (payload as WebsocketConnectedPayload).status = 101;
186 }
187 setResponses((rps) => ({
188 ...rps,
189 [(payload as ResponsePayload | WebsocketConnectedPayload).id]: {
190 type,
191 payload,
192 } as Response | WebsocketConnected,
193 }));
194 break;
195 case "websocket_inbound":
196 case "websocket_outbound":
197 case "websocket_disconnect":
198 setWebsocketFrames((frms) => {
199 const id = (
200 payload as
201 | WebsocketInboundPayload
202 | WebsocketOutboundPayload
203 | WebsocketDisconnectPayload
204 ).id;
205
206 const newFrms = { ...frms };
207
208 if (!newFrms.hasOwnProperty(id)) {
209 newFrms[id] = [];
210 }
211
212 // @ts-ignore
213 newFrms[id].push({
214 type,
215 payload: payload as
216 | WebsocketInboundPayload
217 | WebsocketOutboundPayload
218 | WebsocketDisconnectPayload,
219 });
220
221 return newFrms;
222 });
223 break;
224 }
225 };
226
227 ws.addEventListener("message", onMessage);
228 ws.addEventListener("close", onClose);
229 ws.addEventListener("open", onOpen);
230
231 return () => {
232 ws.removeEventListener("message", onMessage);
233 ws.removeEventListener("close", onClose);
234 ws.removeEventListener("open", onOpen);
235 };
236 }, [ws]);
237
238 const calls = useMemo<Call[]>(
239 () =>
240 requests.map((request) => {
241 let call: Call;
242 if (request.type === "request") {
243 call = {
244 type: "RequestResponse",
245 request: request.payload,
246 response: responses[request.payload.id]?.payload as ResponsePayload,
247 };
248 } else {
249 call = {
250 type: "WebsocketConnection",
251 request: request.payload,
252 response: responses[request.payload.id]
253 ?.payload as WebsocketConnectedPayload,
254 frames: websocketFrames[request.payload.id] ?? [],
255 };
256 }
257
258 if (
259 selectedCall !== null &&
260 selectedCall.request.id === call.request.id
261 ) {
262 setSelectedCall(call);
263 }
264
265 return call;
266 }),
267 [requests, responses, websocketFrames]
268 );
269
270 return (
271 <ConnectionContext.Provider
272 value={{
273 config,
274 calls,
275 selectedCall,
276 setSelectedCall,
277 readyState,
278 clear: () => {
279 setRequests([]);
280 setResponses({});
281 setWebsocketFrames({});
282 },
283 }}
284 >
285 {children}
286 </ConnectionContext.Provider>
287 );
288}
diff --git a/src/contexts/DarkMode.tsx b/src/contexts/DarkMode.tsx
index 4326fe9..fd536b2 100644
--- a/src/contexts/DarkMode.tsx
+++ b/src/contexts/DarkMode.tsx
@@ -2,7 +2,7 @@ import * as React from "react";
2import { 2import {
3 Context, 3 Context,
4 createContext, 4 createContext,
5 ReactNode, 5 PropsWithChildren,
6 useEffect, 6 useEffect,
7 useMemo, 7 useMemo,
8 useState, 8 useState,
@@ -17,11 +17,8 @@ interface DarkModeApi {
17export const DarkModeContext = createContext<Partial<DarkModeApi>>( 17export const DarkModeContext = createContext<Partial<DarkModeApi>>(
18 {} 18 {}
19) as Context<DarkModeApi>; 19) as Context<DarkModeApi>;
20interface DarkModeProviderProps {
21 children: ReactNode;
22}
23 20
24export default function DarkModeProvider({ children }: DarkModeProviderProps) { 21export default function DarkModeProvider({ children }: PropsWithChildren<any>) {
25 const themeConfig = useMemo(() => new ThemeConfig(), []); 22 const themeConfig = useMemo(() => new ThemeConfig(), []);
26 const [darkMode, setDarkMode] = useState( 23 const [darkMode, setDarkMode] = useState(
27 () => themeConfig.getTheme() === "dark" 24 () => themeConfig.getTheme() === "dark"
diff --git a/src/hooks/useRequests.tsx b/src/hooks/useRequests.tsx
index 1361949..3135aa2 100644
--- a/src/hooks/useRequests.tsx
+++ b/src/hooks/useRequests.tsx
@@ -1,68 +1,32 @@
1import { useCallback, useEffect, useMemo, useState } from "react"; 1import { useCallback, useEffect, useMemo, useState } from "react";
2import { getHost } from "~/utils"; 2import { getHost } from "~/utils";
3 3import {
4export type Headers = [string, string][]; 4 Call,
5export type Method = 5 Frame,
6 | "GET" 6 Frames,
7 | "HEAD" 7 Historic,
8 | "POST" 8 ReadyState,
9 | "PUT" 9 Request,
10 | "DELETE" 10 RequestResponseType,
11 | "CONNECT" 11 Requests,
12 | "OPTIONS" 12 Response,
13 | "TRACE" 13 ResponsePayload,
14 | "PATCH"; 14 Responses,
15 15 WebsocketConnect,
16export interface RequestPayload { 16 WebsocketConnected,
17 id: string; 17 WebsocketConnectedPayload,
18 timestamp: string; 18 WebsocketDisconnectPayload,
19 body: string; 19 WebsocketInboundPayload,
20 headers: Headers; 20 WebsocketOutboundPayload,
21 method: Method; 21 WebsocketType,
22 path: string; 22} from "~/types";
23}
24
25interface Request {
26 type: "request";
27 payload: RequestPayload;
28}
29
30export interface ResponsePayload {
31 id: string;
32 timing: number;
33 body: string;
34 headers: Headers;
35 status: number;
36}
37
38interface Response {
39 type: "response";
40 payload: ResponsePayload;
41}
42
43interface Historic {
44 type: "historic";
45 payload: (Request | Response)[];
46}
47
48export interface RequestResponse {
49 request: RequestPayload;
50 response?: ResponsePayload;
51}
52
53export enum ReadyState {
54 CONNECTING = 0,
55 OPEN = 1,
56 CLOSING = 2,
57 CLOSED = 3,
58}
59 23
60export interface useRequestsProps { 24export interface useRequestsProps {
61 onConnect: () => Promise<void>; 25 onConnect: () => Promise<void>;
62} 26}
63 27
64export interface UseRequests { 28export interface UseRequests {
65 calls: RequestResponse[]; 29 calls: Call[];
66 readyState: ReadyState; 30 readyState: ReadyState;
67 clear: () => void; 31 clear: () => void;
68} 32}
@@ -73,8 +37,9 @@ export default function useRequests({
73 const wsHost = useMemo(getHost, []); 37 const wsHost = useMemo(getHost, []);
74 38
75 const [initialConnection, setInitialConnection] = useState(true); 39 const [initialConnection, setInitialConnection] = useState(true);
76 const [requests, setRequests] = useState<RequestPayload[]>([]); 40 const [requests, setRequests] = useState<Requests>([]);
77 const [responses, setResponses] = useState<ResponsePayload[]>([]); 41 const [responses, setResponses] = useState<Responses>({});
42 const [websocketFrames, setWebsocketFrames] = useState<Frames>({});
78 43
79 const connect = useCallback( 44 const connect = useCallback(
80 () => new WebSocket(`ws://${wsHost}/inspect/`), 45 () => new WebSocket(`ws://${wsHost}/inspect/`),
@@ -96,36 +61,140 @@ export default function useRequests({
96 setInitialConnection(false); 61 setInitialConnection(false);
97 setReadyState(ws.readyState); 62 setReadyState(ws.readyState);
98 }; 63 };
99 const onMessage = ({ data }) => { 64 const onMessage = ({ data }: { data: string }) => {
100 const { type, payload } = JSON.parse(data) as 65 const { type, payload } = JSON.parse(data) as
101 | Historic 66 | Historic
102 | Request 67 | RequestResponseType
103 | Response; 68 | WebsocketType;
104 69
105 switch (type) { 70 switch (type) {
106 case "historic": 71 case "historic":
107 if (initialConnection) { 72 if (initialConnection) {
108 const requests = (payload as (Request | Response)[]).filter( 73 const requests = (
109 ({ type }) => type === "request" 74 payload as (RequestResponseType | WebsocketType)[]
110 ); 75 )
111 const responses = (payload as (Request | Response)[]).filter( 76 .filter(
112 ({ type }) => type === "response" 77 ({ type }) => type === "request" || type == "websocket_connect"
113 ); 78 )
79 .map((payload) => {
80 if (
81 (payload as WebsocketConnect).payload.method === undefined
82 ) {
83 (payload as Request | WebsocketConnect).payload.method =
84 "GET";
85 }
86
87 return payload;
88 });
89 const responses = (
90 payload as (RequestResponseType | WebsocketType)[]
91 )
92 .filter(
93 ({ type }) =>
94 type === "response" || type == "websocket_connected"
95 )
96 .map(({ type, ...item }) => {
97 if (type == "websocket_connected") {
98 (item.payload as WebsocketConnectedPayload).status = 101;
99 }
100
101 return { type, ...item };
102 })
103 .reduce<{ [id: string]: Response | WebsocketConnected }>(
104 (out, item) => ({
105 ...out,
106 [item.payload.id]: item as Response | WebsocketConnected,
107 }),
108 {}
109 );
110 const frames = (payload as (RequestResponseType | WebsocketType)[])
111 .filter(
112 ({ type }) =>
113 type == "websocket_inbound" ||
114 type == "websocket_outbound" ||
115 type == "websocket_disconnect"
116 )
117 .reduce<Frames>((out, item) => {
118 if (!out.hasOwnProperty(item.payload.id)) {
119 out[item.payload.id] = [];
120 }
121
122 out[item.payload.id].push(item as Frame);
123 return out;
124 }, {});
114 setRequests((rqs) => [ 125 setRequests((rqs) => [
115 ...rqs, 126 ...rqs,
116 ...requests.map(({ payload }) => payload as RequestPayload), 127 ...requests.map(
128 (payload) => payload as Request | WebsocketConnect
129 ),
117 ]); 130 ]);
118 setResponses((rps) => [ 131 setResponses((rps) => ({
119 ...rps, 132 ...rps,
120 ...responses.map(({ payload }) => payload as ResponsePayload), 133 ...responses,
121 ]); 134 }));
135 setWebsocketFrames((frms) => ({
136 ...frms,
137 ...frames,
138 }));
122 } 139 }
123 break; 140 break;
124 case "request": 141 case "request":
125 setRequests((rqs) => [...rqs, payload as RequestPayload]); 142 case "websocket_connect":
143 setRequests((rqs) => [
144 ...rqs,
145 {
146 type,
147 payload:
148 type === "request"
149 ? payload
150 : {
151 ...payload,
152 method: "GET",
153 },
154 } as Request | WebsocketConnect,
155 ]);
126 break; 156 break;
127 case "response": 157 case "response":
128 setResponses((rps) => [...rps, payload as ResponsePayload]); 158 case "websocket_connected":
159 if (type == "websocket_connected") {
160 (payload as WebsocketConnectedPayload).status = 101;
161 }
162 setResponses((rps) => ({
163 ...rps,
164 [(payload as ResponsePayload | WebsocketConnectedPayload).id]: {
165 type,
166 payload,
167 } as Response | WebsocketConnected,
168 }));
169 break;
170 case "websocket_inbound":
171 case "websocket_outbound":
172 case "websocket_disconnect":
173 setWebsocketFrames((frms) => {
174 const id = (
175 payload as
176 | WebsocketInboundPayload
177 | WebsocketOutboundPayload
178 | WebsocketDisconnectPayload
179 ).id;
180
181 const newFrms = { ...frms };
182
183 if (!newFrms.hasOwnProperty(id)) {
184 newFrms[id] = [];
185 }
186
187 // @ts-ignore
188 newFrms[id].push({
189 type,
190 payload: payload as
191 | WebsocketInboundPayload
192 | WebsocketOutboundPayload
193 | WebsocketDisconnectPayload,
194 });
195
196 return newFrms;
197 });
129 break; 198 break;
130 } 199 }
131 }; 200 };
@@ -141,19 +210,37 @@ export default function useRequests({
141 }; 210 };
142 }, [ws]); 211 }, [ws]);
143 212
213 const calls = useMemo<Call[]>(
214 () =>
215 requests.map((request) => {
216 switch (request.type) {
217 case "request":
218 return {
219 type: "RequestResponse",
220 request: request.payload,
221 response: responses[request.payload.id]
222 ?.payload as ResponsePayload,
223 };
224 case "websocket_connect":
225 return {
226 type: "WebsocketConnection",
227 request: request.payload,
228 response: responses[request.payload.id]
229 ?.payload as WebsocketConnectedPayload,
230 frames: websocketFrames[request.payload.id] ?? [],
231 };
232 }
233 }),
234 [requests, responses, websocketFrames]
235 );
236
144 return { 237 return {
145 calls: useMemo<RequestResponse[]>( 238 calls,
146 () =>
147 requests.map((request) => ({
148 request: request,
149 response: responses.find(({ id }) => id === request.id),
150 })),
151 [requests, responses]
152 ),
153 readyState, 239 readyState,
154 clear: () => { 240 clear: () => {
155 setRequests([]); 241 setRequests([]);
156 setResponses([]); 242 setResponses({});
243 setWebsocketFrames({});
157 }, 244 },
158 }; 245 };
159} 246}
diff --git a/src/index.tsx b/src/index.tsx
index d83fd38..a2c18e3 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -7,11 +7,14 @@ import DarkModeProvider from "./contexts/DarkMode";
7 7
8import dayjs from "dayjs"; 8import dayjs from "dayjs";
9import localizedFormat from "dayjs/plugin/localizedFormat"; 9import localizedFormat from "dayjs/plugin/localizedFormat";
10import ConnectionProvider from "~/contexts/Connection";
10dayjs.extend(localizedFormat); 11dayjs.extend(localizedFormat);
11 12
12ReactDOM.render( 13ReactDOM.render(
13 <DarkModeProvider> 14 <DarkModeProvider>
14 <App /> 15 <ConnectionProvider>
16 <App />
17 </ConnectionProvider>
15 </DarkModeProvider>, 18 </DarkModeProvider>,
16 document.getElementById("root") 19 document.getElementById("root")
17); 20);
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..0e11bee
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,136 @@
1export type Headers = [string, string][];
2export type Method =
3 | "GET"
4 | "HEAD"
5 | "POST"
6 | "PUT"
7 | "DELETE"
8 | "CONNECT"
9 | "OPTIONS"
10 | "TRACE"
11 | "PATCH";
12
13export interface RequestPayload {
14 id: string;
15 timestamp: string;
16 body: string;
17 headers: Headers;
18 method: Method;
19 path: string;
20}
21
22export interface Request {
23 type: "request";
24 payload: RequestPayload;
25}
26
27export interface ResponsePayload {
28 id: string;
29 timing: number;
30 body: string;
31 headers: Headers;
32 status: number;
33}
34
35export interface Response {
36 type: "response";
37 payload: ResponsePayload;
38}
39
40export type RequestResponseType = Request | Response;
41
42export interface WebsocketConnectPayload {
43 method: "GET";
44 id: string;
45 headers: Headers;
46 path: string;
47 timestamp: string;
48}
49
50export interface WebsocketConnect {
51 type: "websocket_connect";
52 payload: WebsocketConnectPayload;
53}
54
55export interface WebsocketConnectedPayload {
56 id: string;
57 timing: number;
58 status: 101;
59}
60
61export interface WebsocketConnected {
62 type: "websocket_connected";
63 payload: WebsocketConnectedPayload;
64}
65
66export interface WebsocketInboundPayload {
67 id: string;
68 timestamp: string;
69 body: string;
70}
71
72interface WebsocketInbound {
73 type: "websocket_inbound";
74 payload: WebsocketInboundPayload;
75}
76
77export interface WebsocketOutboundPayload {
78 id: string;
79 timestamp: string;
80 body: string;
81}
82
83interface WebsocketOutbound {
84 type: "websocket_outbound";
85 payload: WebsocketOutboundPayload;
86}
87
88export interface WebsocketDisconnectPayload {
89 id: string;
90 timestamp: string;
91 close_code: number;
92}
93
94interface WebsocketDisconnect {
95 type: "websocket_disconnect";
96 payload: WebsocketDisconnectPayload;
97}
98
99export type WebsocketType =
100 | WebsocketConnect
101 | WebsocketConnected
102 | WebsocketInbound
103 | WebsocketOutbound
104 | WebsocketDisconnect;
105
106export interface Historic {
107 type: "historic";
108 payload: (RequestResponseType | WebsocketType)[];
109}
110
111export interface RequestResponse {
112 type: "RequestResponse";
113 request: RequestPayload;
114 response?: ResponsePayload;
115}
116
117export type Frame = WebsocketDisconnect | WebsocketInbound | WebsocketOutbound;
118export interface WebsocketConnection {
119 type: "WebsocketConnection";
120 request: WebsocketConnectPayload;
121 response?: WebsocketConnectedPayload;
122 frames?: Frame[];
123}
124
125export type Call = RequestResponse | WebsocketConnection;
126
127export type Requests = Array<Request | WebsocketConnect>;
128export type Responses = { [id: string]: Response | WebsocketConnected };
129export type Frames = { [id: string]: Frame[] };
130
131export enum ReadyState {
132 CONNECTING = 0,
133 OPEN = 1,
134 CLOSING = 2,
135 CLOSED = 3,
136}