diff --git a/examples/apollo-client/package.json b/examples/apollo-client/package.json index 4597839..969f04f 100644 --- a/examples/apollo-client/package.json +++ b/examples/apollo-client/package.json @@ -5,9 +5,10 @@ "license": "MIT", "private": true, "scripts": { - "start": "parcel ./src/index.html --port 4001" + "start": "parcel ./src/index.html --port 4001 --no-cache" }, "dependencies": { + "@envy/web": "*", "react": "^18.2.0", "react-dom": "^18.2.0", "urql": "^4.0.5" diff --git a/examples/apollo-client/src/components/Sanity.tsx b/examples/apollo-client/src/components/Sanity.tsx index be233a5..6154f08 100644 --- a/examples/apollo-client/src/components/Sanity.tsx +++ b/examples/apollo-client/src/components/Sanity.tsx @@ -26,7 +26,7 @@ export default function Sanity() {
{category.products.map((product: any) => { return product.variants.map((variant: any) => ( -
+
{variant.name}
${variant.price.toFixed(2)}
diff --git a/examples/apollo-client/src/index.js b/examples/apollo-client/src/index.js index 3e05c2b..d8bb8a8 100644 --- a/examples/apollo-client/src/index.js +++ b/examples/apollo-client/src/index.js @@ -1,7 +1,11 @@ +import { enableTracing } from '@envy/web'; import { createRoot } from 'react-dom/client'; import { App } from './App'; const container = document.getElementById('app'); const root = createRoot(container); -root.render(); + +enableTracing({ serviceName: 'examples/apollo-client', debug: true, port: 9999 }).then(() => { + root.render(); +}); diff --git a/packages/browser/src/components/TraceDetail.tsx b/packages/browser/src/components/TraceDetail.tsx index 9607d63..ae7ae2a 100644 --- a/packages/browser/src/components/TraceDetail.tsx +++ b/packages/browser/src/components/TraceDetail.tsx @@ -22,7 +22,8 @@ type DetailProps = React.HTMLAttributes; function CodeDisplay({ contentType, children }: CodeDisplayProps) { if (!children) return null; - const isJson = contentType?.includes('application/json'); + const isJson = + contentType?.includes('application/json') || contentType?.includes('application/graphql-response+json'); const isXml = contentType?.includes('application/xml'); return ( @@ -42,8 +43,18 @@ export default function TraceDetail({ className }: DetailProps) { const { getSelectedTrace, clearSelectedTrace } = useApplication(); const trace = getSelectedTrace(); - const { timestamp, method, host, url, requestHeaders, statusCode, statusMessage, responseHeaders, duration } = - trace || {}; + const { + serviceName, + timestamp, + method, + host, + url, + requestHeaders, + statusCode, + statusMessage, + responseHeaders, + duration, + } = trace || {}; const responseComplete = duration !== undefined && statusCode !== undefined; const updateTimer = useCallback(() => { @@ -111,6 +122,9 @@ export default function TraceDetail({ className }: DetailProps) { {url}
+
+ Sent from {serviceName} +
diff --git a/packages/browser/src/components/TraceList.tsx b/packages/browser/src/components/TraceList.tsx index 076d92e..81088ec 100644 --- a/packages/browser/src/components/TraceList.tsx +++ b/packages/browser/src/components/TraceList.tsx @@ -1,4 +1,4 @@ -import { HiOutlineEmojiSad } from 'react-icons/hi'; +import { HiOutlineChartBar, HiOutlineEmojiSad, HiOutlineLightningBolt } from 'react-icons/hi'; import { Loading } from '@/components/ui'; import useApplication from '@/hooks/useApplication'; @@ -65,20 +65,17 @@ export default function TraceList({ className }: TraceListProps) { ['Time', getRequestDuration, 'w-[50px] md:w-[100px] text-center', () => ''], ]; + const [Icon, message] = connected + ? [HiOutlineLightningBolt, `Connected to ws://localhost:${port}...`] + : connecting + ? [HiOutlineChartBar, 'Connecting...'] + : [HiOutlineEmojiSad, 'Unable to connect']; + return (
{data.length === 0 ? ( -
- {connected ? ( - `Server established on ws://localhost:${port} - waiting for data...` - ) : connecting ? ( - 'connecting...' - ) : ( - - - unable to connect - - )} +
+ {message}
) : (
diff --git a/packages/browser/src/model/CollectorClient.ts b/packages/browser/src/model/CollectorClient.ts index eb7a660..690e2c8 100644 --- a/packages/browser/src/model/CollectorClient.ts +++ b/packages/browser/src/model/CollectorClient.ts @@ -1,4 +1,4 @@ -import { Event, EventType, HttpRequest } from '@envy/core'; +import { DEFAULT_WEB_SOCKET_PORT, Event, EventType, HttpRequest } from '@envy/core'; import { Traces } from '@/types'; import { safeParseJson } from '@/utils'; @@ -13,8 +13,6 @@ export default class CollectorClient { private _connected: boolean = true; private _connecting: boolean = false; - private _shouldRetry: boolean = true; - private _retryCount: number = 0; private _traces: Traces = new Map(); private _changeHandler?: () => void; @@ -44,12 +42,12 @@ export default class CollectorClient { } private _connect() { - const socket = new WebSocket(`ws://localhost:${this._port}/viewer`); + const port = this._port ?? DEFAULT_WEB_SOCKET_PORT; + const socket = new WebSocket(`ws://localhost:${port}/viewer`); socket.onopen = () => { this._connecting = false; this._connected = true; - this._retryCount = 0; this._signalChange(); }; @@ -57,15 +55,6 @@ export default class CollectorClient { this._connected = false; this._connecting = true; this._signalChange(); - this._retryCount += 1; - if (this._shouldRetry && this._retryCount < 3) { - // TODO: implement incremental back-off? - setTimeout(this._connect, 3000); - } else { - this._shouldRetry = false; - this._connecting = false; - this._signalChange(); - } }; socket.onmessage = ({ data }) => { diff --git a/packages/browser/src/model/mockData.ts b/packages/browser/src/model/mockData.ts index 2552630..f886b00 100644 --- a/packages/browser/src/model/mockData.ts +++ b/packages/browser/src/model/mockData.ts @@ -14,12 +14,11 @@ function requestData( host: Trace['host'], port: Trace['port'], path: Trace['path'], -): Pick { +): Pick { const protocol = port === 433 ? 'https://' : 'http://'; const hostString = port === 80 || port === 443 ? `${host}` : `${host}:${port.toString()}`; return { - httpVersion: '1.1', method, host, port, @@ -34,6 +33,7 @@ const mockTraces: Trace[] = [ id: '1', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'gql', timestamp: elapseTime(0), ...requestData('GET', 'auth.restserver.com', 443, '/auth?client=mock_client'), requestHeaders: { @@ -47,6 +47,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -71,6 +72,7 @@ const mockTraces: Trace[] = [ id: '2', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'web', timestamp: elapseTime(0.1), ...requestData('POST', 'localhost', 3000, '/api/graphql'), requestHeaders: { @@ -119,6 +121,7 @@ const mockTraces: Trace[] = [ id: '3', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'gql', timestamp: elapseTime(1.2), ...requestData('GET', 'data.restserver.com', 443, '/features'), requestHeaders: { @@ -128,6 +131,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -149,6 +153,7 @@ const mockTraces: Trace[] = [ id: '4', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'gql', timestamp: elapseTime(3.1), ...requestData('GET', 'data.restserver.com', 443, '/countries?start=0&count=20'), requestHeaders: { @@ -158,6 +163,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 404, statusMessage: 'Not found', responseHeaders: { @@ -176,6 +182,7 @@ const mockTraces: Trace[] = [ id: '5', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'gql', timestamp: elapseTime(16.3), ...requestData('POST', 'data.restserver.com', 443, '/people'), requestHeaders: { @@ -191,6 +198,7 @@ const mockTraces: Trace[] = [ lastName: 'Bear', }), // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -205,7 +213,6 @@ const mockTraces: Trace[] = [ 'connection': 'keep-alive', 'keep-alive': 'timeout=5', }, - httpVersion: '1.1', responseBody: JSON.stringify({ id: '4', }), @@ -217,6 +224,7 @@ const mockTraces: Trace[] = [ id: '6', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'web', timestamp: elapseTime(0.1), ...requestData('POST', 'localhost', 3000, '/api/graphql'), requestHeaders: { @@ -239,6 +247,7 @@ const mockTraces: Trace[] = [ }, }), // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -267,6 +276,7 @@ const mockTraces: Trace[] = [ id: '7', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'gql', timestamp: elapseTime(3.14), ...requestData('GET', 'data.restserver.com', 433, '/movies?start=0&count=20'), requestHeaders: { @@ -276,6 +286,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 500, statusMessage: 'Internal Server Error', responseHeaders: { @@ -294,6 +305,7 @@ const mockTraces: Trace[] = [ id: '8', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'gql', timestamp: elapseTime(3.14), ...requestData('GET', 'hits.webstats.com', 433, '/?apikey=c82e66bd-4d5b-4bb7-b439-896936c94eb2'), requestHeaders: { @@ -303,6 +315,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -321,6 +334,7 @@ const mockTraces: Trace[] = [ id: '9', parentId: undefined, type: EventType.HttpRequest, + serviceName: 'gql', timestamp: elapseTime(0.4), ...requestData('GET', 'data.restserver.com', 433, '/features'), requestHeaders: { @@ -330,6 +344,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: undefined, statusMessage: undefined, responseHeaders: undefined, diff --git a/packages/browser/src/scripts/startCollector.cjs b/packages/browser/src/scripts/startCollector.cjs index 725ea1d..910086e 100644 --- a/packages/browser/src/scripts/startCollector.cjs +++ b/packages/browser/src/scripts/startCollector.cjs @@ -18,21 +18,18 @@ wss.on('listening', () => { }); wss.on('connection', (ws, request) => { - if (request.url === '/viewer' && !viewer) { + if (request.url === '/viewer') { log(chalk.green('✅ Envy viewer client connected')); viewer = ws; } - if (request.url === '/node' && !viewer) { + if (request.startsWith === '/node') { log(chalk.green('✅ Envy node sender connected')); } - ws.on('close', () => { - if (viewer !== null) { - log(chalk.red('❌ Envy viewer client disconnected')); - viewer = null; - } - }); + if (request.startsWith === '/web') { + log(chalk.green('✅ Envy web sender connected')); + } ws.on('message', data => { if (!viewer || viewer.readyState !== WebSocket.OPEN) { diff --git a/packages/browser/src/systems/GraphQL.tsx b/packages/browser/src/systems/GraphQL.tsx index 7c640e2..5217178 100644 --- a/packages/browser/src/systems/GraphQL.tsx +++ b/packages/browser/src/systems/GraphQL.tsx @@ -21,7 +21,7 @@ export default class GraphQL implements System { name = 'GraphQL'; isMatch(trace: Trace) { - return trace.path === '/api/graphql'; + return trace.path?.endsWith('/graphql') ?? false; } getData(trace: Trace) { diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..7c38720 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "@envy/web", + "version": "0.1.0", + "description": "Node.js Network & Telemetry Viewer", + "main": "dist/index.js", + "repository": "https://github.com/FormidableLabs/envy.git", + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "tsc" + }, + "dependencies": { + "@envy/core": "0.2.0" + } +} diff --git a/packages/web/src/http.ts b/packages/web/src/http.ts new file mode 100644 index 0000000..b65cf61 --- /dev/null +++ b/packages/web/src/http.ts @@ -0,0 +1,64 @@ +import { EventType, HttpRequest } from '@envy/core'; + +function formatHeaders(headers: HeadersInit | Headers | undefined): HttpRequest['requestHeaders'] { + if (headers) { + if (Array.isArray(headers)) { + return headers.reduce((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {}); + } else if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } else { + return headers; + } + } + + return {}; +} + +export function fetchRequestToEvent( + timestamp: number, + id: string, + input: RequestInfo | URL, + init?: RequestInit, +): HttpRequest { + let url: URL; + if (typeof input === 'string') { + url = new URL(input); + } else if (input instanceof Request) { + url = new URL(input.url); + } else { + url = input; + } + + return { + id, + parentId: undefined, + timestamp, + type: EventType.HttpRequest, + method: (init?.method ?? 'GET') as HttpRequest['method'], + host: url.host, + port: parseInt(url.port, 10), + path: url.pathname, + url: url.toString(), + requestHeaders: formatHeaders(init?.headers), + requestBody: init?.body?.toString() ?? undefined, + }; +} + +export async function fetchResponseToEvent( + timestamp: number, + req: HttpRequest, + response: Response, +): Promise { + return { + ...req, + httpVersion: response.type, + statusCode: response.status, + statusMessage: response.statusText, + responseHeaders: formatHeaders(response.headers), + responseBody: await response.text(), + duration: timestamp - req.timestamp, + }; +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts new file mode 100644 index 0000000..8af7bc5 --- /dev/null +++ b/packages/web/src/index.ts @@ -0,0 +1 @@ +export * from './tracing'; diff --git a/packages/web/src/log.ts b/packages/web/src/log.ts new file mode 100644 index 0000000..1383570 --- /dev/null +++ b/packages/web/src/log.ts @@ -0,0 +1,10 @@ +/* eslint-disable no-console */ + +const { name } = require('../package.json'); + +export default { + info: (msg: string, ...args: unknown[]) => console.log(`✅ %c${name} ${msg}`, 'color: green', ...args), + warn: (msg: string, ...args: unknown[]) => console.log(`🚸 %c${name} ${msg}`, 'color: yellow', ...args), + error: (msg: string, ...args: unknown[]) => console.log(`❌ %c${name} ${msg}`, 'color: red', ...args), + debug: (msg: string, ...args: unknown[]) => console.log(`🔧 %c$${name} ${msg}`, 'color: cyan', ...args), +}; diff --git a/packages/web/src/nanoid.ts b/packages/web/src/nanoid.ts new file mode 100644 index 0000000..0861acb --- /dev/null +++ b/packages/web/src/nanoid.ts @@ -0,0 +1,8 @@ +export const nanoid = (t = 21) => + crypto + .getRandomValues(new Uint8Array(t)) + .reduce( + (t: any, e: any) => + (t += (e &= 63) < 36 ? e.toString(36) : e < 62 ? (e - 26).toString(36).toUpperCase() : e < 63 ? '_' : '-'), + '', + ); diff --git a/packages/web/src/options.ts b/packages/web/src/options.ts new file mode 100644 index 0000000..1156db7 --- /dev/null +++ b/packages/web/src/options.ts @@ -0,0 +1,4 @@ +export interface Options { + serviceName: string; + debug?: boolean; +} diff --git a/packages/web/src/tracing.ts b/packages/web/src/tracing.ts new file mode 100644 index 0000000..003b7a8 --- /dev/null +++ b/packages/web/src/tracing.ts @@ -0,0 +1,81 @@ +import { DEFAULT_WEB_SOCKET_PORT, HttpRequest } from '@envy/core'; + +import { fetchRequestToEvent, fetchResponseToEvent } from './http'; +import log from './log'; +import { nanoid } from './nanoid'; +import { Options } from './options'; + +export interface TracingOptions extends Options { + port?: number; +} + +const initialTraces: Record = {}; + +export async function enableTracing(options: TracingOptions): Promise { + if (typeof window === 'undefined') { + log.error('Attempted to use @envy/web in a non-browser environment'); + return Promise.resolve(); + } + + return new Promise(resolve => { + if (options.debug) log.info('Starting in debug mode'); + + const port = options.port ?? DEFAULT_WEB_SOCKET_PORT; + const serviceName = options.serviceName; + + const wsUri = `ws://127.0.0.1:${port}/web/${serviceName}`; + const ws = new WebSocket(wsUri); + + const { fetch: originalFetch } = window; + window.fetch = async (...args) => { + const tsReq = Date.now(); + const id = nanoid(); + + const reqEvent = fetchRequestToEvent(tsReq, id, ...args); + if (ws.readyState === ws.OPEN) { + ws.send( + JSON.stringify({ + ...reqEvent, + serviceName, + }), + ); + } else { + initialTraces[id] = reqEvent; + } + + const response = await originalFetch(...args); + const tsRes = Date.now(); + const responseClone = response.clone(); + const resEvent = await fetchResponseToEvent(tsRes, reqEvent, responseClone); + + if (ws.readyState === ws.OPEN) { + ws.send( + JSON.stringify({ + ...resEvent, + serviceName, + }), + ); + } else { + initialTraces[id] = resEvent; + } + + return response; + }; + + ws.onopen = () => { + if (options.debug) log.info(`Connected to ${wsUri}`); + + // flush any request traces captured prior to the socket being open + for (const trace of Object.entries(initialTraces)) { + ws.send( + JSON.stringify({ + ...trace, + serviceName, + }), + ); + } + + resolve(); + }; + }); +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..2f366c8 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["**/*.spec.ts"], + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist" + } +}