- {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"
+ }
+}