Skip to content

Commit

Permalink
Show HTTP->WS upgrade (handshake) (#5091)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
jackkav and gatzjames authored Aug 19, 2022
1 parent 3bb70e2 commit 5e8b3a6
Show file tree
Hide file tree
Showing 21 changed files with 342 additions and 482 deletions.
113 changes: 87 additions & 26 deletions packages/insomnia/src/main/network/websocket.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ipcMain } from 'electron';
import electron, { ipcMain } from 'electron';
import fs from 'fs';
import mkdirp from 'mkdirp';
import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import {
CloseEvent,
Expand All @@ -8,8 +11,13 @@ import {
WebSocket,
} from 'ws';

import { generateId } from '../../common/misc';
import { websocketRequest } from '../../models';
import * as models from '../../models';
import type { Response } from '../../models/response';
import { BaseWebSocketRequest } from '../../models/websocket-request';
import { storeTimeline } from '../../network/network';
import { ResponseTimelineEntry } from './libcurl-promise';

export interface WebSocketConnection extends WebSocket {
_id: string;
Expand Down Expand Up @@ -53,9 +61,8 @@ export type WebsocketEvent =

export type WebSocketEventLog = WebsocketEvent[];

// @TODO: Volatile state for now, later we might want to persist event logs.
const WebSocketConnections = new Map<string, WebSocket>();
const WebSocketEventLogs = new Map<string, WebSocketEventLog>();
const fileStreams = new Map<string, fs.WriteStream>();

async function createWebSocketConnection(
event: Electron.IpcMainInvokeEvent,
Expand All @@ -70,12 +77,12 @@ async function createWebSocketConnection(

try {
const request = await websocketRequest.getById(options.requestId);

const responseId = generateId('res');
if (!request?.url) {
throw new Error('No URL specified');
}

const eventChannel = `webSocketRequest.connection.${request._id}.event`;
const eventChannel = `webSocketRequest.connection.${responseId}.event`;
const readyStateChannel = `webSocketRequest.connection.${request._id}.readyState`;

// @TODO: Render nunjucks tags in these headers
Expand All @@ -84,8 +91,51 @@ async function createWebSocketConnection(
const headers = request.headers.filter(({ value, disabled }) => !!value && !disabled)
.reduce(reduceArrayToLowerCaseKeyedDictionary, {});

const ws = new WebSocket(request?.url, { headers });
const ws = new WebSocket(request.url, { headers });
WebSocketConnections.set(options.requestId, ws);
const start = performance.now();
const responsesDir = path.join(process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData'), 'responses');
mkdirp.sync(responsesDir);
const responseBodyPath = path.join(responsesDir, uuidV4() + '.response');
fileStreams.set(options.requestId, fs.createWriteStream(responseBodyPath));
ws.on('upgrade', async incoming => {
// @TODO: We may want to add set-cookie handling here.
const timeline: ResponseTimelineEntry[] = [];
// request
timeline.push({ value: `Preparing request to ${request.url}`, name: 'Text', timestamp: Date.now() });
timeline.push({ value: `Current time is ${new Date().toISOString()}`, name: 'Text', timestamp: Date.now() });
// @ts-expect-error -- private property
const internalRequest = ws._req;
timeline.push({ value: 'Using HTTP 1.1', name: 'Text', timestamp: Date.now() });
timeline.push({ value: internalRequest._header, name: 'HeaderOut', timestamp: Date.now() });

// response
const statusMessage = incoming.statusMessage || '';
const statusCode = incoming.statusCode || 0;
const httpVersion = incoming.httpVersion;
timeline.push({ value: `HTTP/${httpVersion} ${statusCode} ${statusMessage}`, name: 'HeaderIn', timestamp: Date.now() });
const responseHeaders = Object.entries(incoming.headers).map(([name, value]) => ({ name, value: value?.toString() || '' }));
const headersIn = responseHeaders.map(({ name, value }) => `${name}: ${value}`).join('\n');
timeline.push({ value: headersIn, name: 'HeaderIn', timestamp: Date.now() });
const timelinePath = await storeTimeline(timeline);
const responsePatch: Partial<Response> = {
_id: responseId,
parentId: request._id,
type: 'upgrade',
headers: responseHeaders,
url: request.url,
statusCode,
statusMessage,
httpVersion,
elapsedTime: performance.now() - start,
timelinePath,
bodyPath: responseBodyPath,
bodyCompression: null,
};
const settings = await models.settings.getOrCreate();
models.response.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: null });
});

ws.addEventListener('open', () => {
const openEvent: WebsocketOpenEvent = {
Expand All @@ -95,14 +145,12 @@ async function createWebSocketConnection(
timestamp: Date.now(),
};

WebSocketEventLogs.set(options.requestId, [openEvent]);

fileStreams.get(options.requestId)?.write(JSON.stringify(openEvent) + '\n');
event.sender.send(eventChannel, openEvent);
event.sender.send(readyStateChannel, ws.readyState);
});

ws.addEventListener('message', ({ data }: MessageEvent) => {
const msgs = WebSocketEventLogs.get(options.requestId) || [];
const messageEvent: WebsocketMessageEvent = {
_id: uuidV4(),
requestId: options.requestId,
Expand All @@ -112,12 +160,11 @@ async function createWebSocketConnection(
timestamp: Date.now(),
};

WebSocketEventLogs.set(options.requestId, [...msgs, messageEvent]);
fileStreams.get(options.requestId)?.write(JSON.stringify(messageEvent) + '\n');
event.sender.send(eventChannel, messageEvent);
});

ws.addEventListener('close', ({ code, reason, wasClean }) => {
const msgs = WebSocketEventLogs.get(options.requestId) || [];
const closeEvent: WebsocketCloseEvent = {
_id: uuidV4(),
requestId: options.requestId,
Expand All @@ -128,15 +175,17 @@ async function createWebSocketConnection(
timestamp: Date.now(),
};

WebSocketEventLogs.set(options.requestId, [...msgs, closeEvent]);
fileStreams.get(options.requestId)?.write(JSON.stringify(closeEvent) + '\n');
fileStreams.get(options.requestId)?.end();
WebSocketConnections.delete(options.requestId);

event.sender.send(eventChannel, closeEvent);
event.sender.send(readyStateChannel, ws.readyState);
});

ws.addEventListener('error', ({ error, message }: ErrorEvent) => {
const msgs = WebSocketEventLogs.get(options.requestId) || [];
console.error(error);

const errorEvent: WebsocketErrorEvent = {
_id: uuidV4(),
requestId: options.requestId,
Expand All @@ -146,7 +195,8 @@ async function createWebSocketConnection(
timestamp: Date.now(),
};

WebSocketEventLogs.set(options.requestId, [...msgs, errorEvent]);
fileStreams.get(options.requestId)?.write(JSON.stringify(errorEvent) + '\n');
fileStreams.get(options.requestId)?.end();
WebSocketConnections.delete(options.requestId);

event.sender.send(eventChannel, errorEvent);
Expand Down Expand Up @@ -187,8 +237,6 @@ async function sendWebSocketEvent(
}
});

const connectionMessages = WebSocketEventLogs.get(options.requestId) || [];

const lastMessage: WebsocketMessageEvent = {
_id: uuidV4(),
requestId: options.requestId,
Expand All @@ -198,11 +246,13 @@ async function sendWebSocketEvent(
timestamp: Date.now(),
};

WebSocketEventLogs.set(options.requestId, [
...connectionMessages,
lastMessage,
]);
const eventChannel = `webSocketRequest.connection.${options.requestId}.event`;
fileStreams.get(options.requestId)?.write(JSON.stringify(lastMessage) + '\n');
const response = await models.response.getLatestByParentId(options.requestId);
if (!response) {
console.error('something went wrong');
return;
}
const eventChannel = `webSocketRequest.connection.${response._id}.event`;
event.sender.send(eventChannel, lastMessage);
}

Expand All @@ -217,18 +267,29 @@ async function closeWebSocketConnection(
ws.close();
}

async function getWebSocketConnectionEvents(
async function findMany(
_event: Electron.IpcMainInvokeEvent,
options: { requestId: string }
options: { responseId: string }
) {
const connectionMessages = WebSocketEventLogs.get(options.requestId) || [];
return connectionMessages;
const response = await models.response.getById(options.responseId);
if (!response || !response.bodyPath) {
return [];
}
const body = await fs.promises.readFile(response.bodyPath);
return body.toString().split('\n').filter(e => e?.trim())
.map(e => JSON.parse(e)) || [];
}

export function registerWebSocketHandlers() {
ipcMain.handle('webSocketRequest.connection.create', createWebSocketConnection);
ipcMain.handle('webSocketRequest.connection.readyState', getWebSocketReadyState);
ipcMain.handle('webSocketRequest.connection.event.send', sendWebSocketEvent);
ipcMain.handle('webSocketRequest.connection.close', closeWebSocketConnection);
ipcMain.handle('webSocketRequest.connection.event.findMany', getWebSocketConnectionEvents);
ipcMain.handle('webSocketRequest.connection.event.findMany', findMany);
}

electron.app.on('window-all-closed', () => {
WebSocketConnections.forEach(ws => {
ws.close();
});
});
20 changes: 10 additions & 10 deletions packages/insomnia/src/models/request-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/insomnia/src/models/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/insomnia/src/network/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 4 additions & 4 deletions packages/insomnia/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ const webSocketConnection = {
},
event: {
findMany: (options: {
requestId: string;
responseId: string;
}): Promise<WebsocketEvent[]> => {
return ipcRenderer.invoke(
'webSocketRequest.connection.event.findMany',
options
);
},
subscribe: (
options: { requestId: string },
options: { responseId: string },
listener: (webSocketEvent: WebsocketEvent) => any
) => {
const channel = `webSocketRequest.connection.${options.requestId}.event`;
const channel = `webSocketRequest.connection.${options.responseId}.event`;

function onNewEvent(_event: IpcRendererEvent, webSocketEvent: WebsocketEvent) {
listener(webSocketEvent);
Expand All @@ -59,7 +59,7 @@ const webSocketConnection = {

return unsubscribe;
},
send(options: { requestId: string; message: string | Blob | ArrayBufferLike | ArrayBufferView }) {
send(options: { requestId: string; message: string }) {
return ipcRenderer.invoke(
'webSocketRequest.connection.event.send',
options
Expand Down
Loading

0 comments on commit 5e8b3a6

Please sign in to comment.