From 95e17e88e76e643788a86c7d107baae3e68bcd1c Mon Sep 17 00:00:00 2001 From: Johan Suleiko Allansson Date: Tue, 17 Dec 2024 12:01:29 +0100 Subject: [PATCH] feat: Websocket communication between k6 Studio and browser (#382) --- extension/src/background.ts | 75 ++++++++++++++++++++++ package-lock.json | 96 ++++++++++++----------------- package.json | 2 + src/browser.ts | 11 +++- src/hooks/useListenBrowserEvent.ts | 16 +++++ src/preload.ts | 4 ++ src/schemas/recording/index.ts | 1 + src/schemas/recording/v1/browser.ts | 14 +++++ src/services/browser/schemas.ts | 18 ++++++ src/services/browser/server.ts | 57 +++++++++++++++++ src/views/Recorder/Recorder.tsx | 3 + tsconfig.json | 6 +- vite.extension.config.mts | 30 +++++---- vite.main.config.ts | 3 +- vite.preload.config.ts | 3 +- 15 files changed, 266 insertions(+), 73 deletions(-) create mode 100644 src/hooks/useListenBrowserEvent.ts create mode 100644 src/schemas/recording/index.ts create mode 100644 src/schemas/recording/v1/browser.ts create mode 100644 src/services/browser/schemas.ts create mode 100644 src/services/browser/server.ts diff --git a/extension/src/background.ts b/extension/src/background.ts index 79a5df81..3cbdd82a 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,5 +1,80 @@ +import { MessageEnvelope, MessagePayload } from '@/services/browser/schemas' import { browser } from 'webextension-polyfill-ts' +let socket: WebSocket | null = null +let buffer: MessageEnvelope[] = [] + +function send(message: MessagePayload) { + const envelope: MessageEnvelope = { + messageId: crypto.randomUUID(), + payload: message, + } + + if (socket) { + socket.send(JSON.stringify(envelope)) + } else { + buffer.push(envelope) + } +} + +function flush(socket: WebSocket) { + for (const message of buffer) { + socket.send(JSON.stringify(message)) + } + + buffer = [] +} + +function reconnect() { + socket?.close() + socket = null + + setTimeout(() => { + connect() + }, 1000) +} + +function connect() { + const ws = new WebSocket('ws://localhost:7554') + + ws.onopen = () => { + console.log('Connected to server...') + + socket = ws + + flush(ws) + } + + ws.onerror = (err) => { + console.log('Connection error...', err) + + reconnect() + } + + ws.onclose = () => { + console.log('Connection closed...') + + reconnect() + } +} + +setInterval(() => { + send({ + type: 'events-captured', + events: [ + { + eventId: crypto.randomUUID(), + timestamp: Date.now(), + type: 'dummy', + selector: 'button', + message: 'Clicked button', + }, + ], + }) +}, 5000) + +connect() + browser.runtime.onInstalled.addListener(() => { console.log('Extension installed...') }) diff --git a/package-lock.json b/package-lock.json index 2f2b0456..96e7dc54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,8 +50,8 @@ "tiny-invariant": "^1.3.3", "tree-kill": "^1.2.2", "update-electron-app": "^3.0.0", - "webextension-polyfill": "^0.12.0", "webextension-polyfill-ts": "^0.26.0", + "ws": "^8.18.0", "zod": "^3.23.8", "zustand": "^4.5.3" }, @@ -75,6 +75,7 @@ "@types/node-forge": "^1.3.11", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^7.16.1", "@typescript-eslint/parser": "^7.16.1", "electron": "30.0.8", @@ -4510,6 +4511,16 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -8897,6 +8908,28 @@ "express": "^4.0.0 || ^5.0.0-alpha.1" } }, + "node_modules/express-ws/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -11278,27 +11311,6 @@ "node": ">=18" } }, - "node_modules/jsdom/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -17536,34 +17548,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/web-ext-run/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webextension-polyfill": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.12.0.tgz", - "integrity": "sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==", - "license": "MPL-2.0" - }, "node_modules/webextension-polyfill-ts": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/webextension-polyfill-ts/-/webextension-polyfill-ts-0.26.0.tgz", @@ -17899,16 +17883,16 @@ "license": "ISC" }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { diff --git a/package.json b/package.json index 7318afa3..31771f5e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/node-forge": "^1.3.11", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^7.16.1", "@typescript-eslint/parser": "^7.16.1", "electron": "30.0.8", @@ -106,6 +107,7 @@ "tree-kill": "^1.2.2", "update-electron-app": "^3.0.0", "webextension-polyfill-ts": "^0.26.0", + "ws": "^8.18.0", "zod": "^3.23.8", "zustand": "^4.5.3" }, diff --git a/src/browser.ts b/src/browser.ts index c3deea2e..de34fa9e 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -10,6 +10,7 @@ import { mkdtemp } from 'fs/promises' import path from 'path' import os from 'os' import { appSettings } from './main' +import { launchBrowserServer } from './services/browser/server' const createUserDataDir = async () => { return mkdtemp(path.join(os.tmpdir(), 'k6-studio-')) @@ -59,7 +60,13 @@ export const launchBrowser = async ( const extensionPath = getExtensionPath() console.info(`extension path: ${extensionPath}`) - const sendBrowserClosedEvent = (): Promise => { + const disposeWebSockerServer = appSettings.recorder.enableBrowserRecorder + ? launchBrowserServer(browserWindow) + : () => {} + + const handleBrowserClose = (): Promise => { + disposeWebSockerServer() + // we send the browser:stopped event when the browser is closed // NOTE: on macos pressing the X button does not close the application so it won't be fired browserWindow.webContents.send('browser:closed') @@ -87,6 +94,6 @@ export const launchBrowser = async ( disableChromeOptimizations, url ?? '', ], - onExit: sendBrowserClosedEvent, + onExit: handleBrowserClose, }) } diff --git a/src/hooks/useListenBrowserEvent.ts b/src/hooks/useListenBrowserEvent.ts new file mode 100644 index 00000000..8fa0e81d --- /dev/null +++ b/src/hooks/useListenBrowserEvent.ts @@ -0,0 +1,16 @@ +import { BrowserEvent } from '@/schemas/recording' +import { useEffect, useState } from 'react' + +export function useListenBrowserEvent() { + const [events, setEvents] = useState([]) + + useEffect(() => { + return window.studio.browser.onBrowserEvent((events: BrowserEvent[]) => { + console.log('Received browser events', events) + + setEvents((prevEvents) => [...prevEvents, ...events]) + }) + }, []) + + return events +} diff --git a/src/preload.ts b/src/preload.ts index c2485c6c..92a54365 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,6 +4,7 @@ import { HarFile } from './types/har' import { GeneratorFile } from './types/generator' import { AddToastPayload } from './types/toast' import { AppSettings } from './types/settings' +import { BrowserEvent } from './schemas/recording' interface GetFilesResponse { recordings: string[] @@ -55,6 +56,9 @@ const browser = { openExternalLink: (url: string) => { return ipcRenderer.invoke('browser:open:external:link', url) }, + onBrowserEvent: (callback: (event: BrowserEvent[]) => void) => { + return createListener('browser:event', callback) + }, } as const const script = { diff --git a/src/schemas/recording/index.ts b/src/schemas/recording/index.ts new file mode 100644 index 00000000..1693a718 --- /dev/null +++ b/src/schemas/recording/index.ts @@ -0,0 +1 @@ +export * from './v1/browser' diff --git a/src/schemas/recording/v1/browser.ts b/src/schemas/recording/v1/browser.ts new file mode 100644 index 00000000..9550ae6f --- /dev/null +++ b/src/schemas/recording/v1/browser.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +const DummyEvent = z.object({ + eventId: z.string(), + timestamp: z.number(), + type: z.literal('dummy'), + selector: z.string(), + message: z.string(), +}) + +export const BrowserEventSchema = DummyEvent + +export type DummyEvent = z.infer +export type BrowserEvent = z.infer diff --git a/src/services/browser/schemas.ts b/src/services/browser/schemas.ts new file mode 100644 index 00000000..cfef3aa3 --- /dev/null +++ b/src/services/browser/schemas.ts @@ -0,0 +1,18 @@ +import { BrowserEventSchema } from '@/schemas/recording' +import { z } from 'zod' + +export const EventsCapturedMessage = z.object({ + type: z.literal('events-captured'), + events: z.array(BrowserEventSchema), +}) + +export const MessagePayload = EventsCapturedMessage + +export const MessageEnvelope = z.object({ + messageId: z.string(), + payload: MessagePayload, +}) + +export type EventsCapturedMessage = z.infer +export type MessagePayload = z.infer +export type MessageEnvelope = z.infer diff --git a/src/services/browser/server.ts b/src/services/browser/server.ts new file mode 100644 index 00000000..a9e6d274 --- /dev/null +++ b/src/services/browser/server.ts @@ -0,0 +1,57 @@ +import { BrowserWindow } from 'electron' +import { RawData, WebSocketServer } from 'ws' +import { MessageEnvelope } from './schemas' + +function tryParseMessage(data: RawData) { + try { + const buffer = data.toString('utf-8') + + return JSON.parse(buffer) as unknown + } catch (error) { + return undefined + } +} + +export function launchBrowserServer(browserWindow: BrowserWindow) { + const ws = new WebSocketServer({ + host: 'localhost', + port: 7554, + }) + + ws.on('connection', (socket) => { + console.log('Browser connected...') + + socket.on('message', (data) => { + const message = tryParseMessage(data) + + if (message === undefined) { + console.log('Failed to parse message as JSON. Dropping.', data) + + return + } + + const parsed = MessageEnvelope.safeParse(message) + + if (!parsed.success) { + console.log('Received malformed message. Dropping.', message) + + return + } + + console.log('received:', parsed.data) + + browserWindow.webContents.send( + 'browser:event', + parsed.data.payload.events + ) + }) + + socket.on('close', () => { + console.log('Browser disconnected...') + }) + }) + + return () => { + ws.close() + } +} diff --git a/src/views/Recorder/Recorder.tsx b/src/views/Recorder/Recorder.tsx index 6a6b93c1..519052bb 100644 --- a/src/views/Recorder/Recorder.tsx +++ b/src/views/Recorder/Recorder.tsx @@ -26,6 +26,7 @@ import { DEFAULT_GROUP_NAME } from '@/constants' import { ButtonWithTooltip } from '@/components/ButtonWithTooltip' import { EmptyState } from './EmptyState' import { EmptyMessage } from '@/components/EmptyMessage' +import { useListenBrowserEvent } from '@/hooks/useListenBrowserEvent' const INITIAL_GROUPS: Group[] = [ { @@ -45,6 +46,8 @@ export function Recorder() { const [recorderState, setRecorderState] = useState('idle') const showToast = useToast() + useListenBrowserEvent() + // Debounce the proxy data to avoid disappearing static asset requests // when recording const debouncedProxyData = useDebouncedProxyData(proxyData) diff --git a/tsconfig.json b/tsconfig.json index 3867f0cf..dc378ba3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,10 @@ "paths": { "@/*": ["src/*"] }, - "types": ["vite/client", "@emotion/react/types/css-prop", "vitest/importMeta"] + "types": [ + "vite/client", + "@emotion/react/types/css-prop", + "vitest/importMeta" + ] } } diff --git a/vite.extension.config.mts b/vite.extension.config.mts index 74c41c8e..d38aa63a 100644 --- a/vite.extension.config.mts +++ b/vite.extension.config.mts @@ -1,4 +1,4 @@ -import type { ConfigEnv, UserConfig } from 'vite' +import type { ConfigEnv, InlineConfig, UserConfig } from 'vite' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' @@ -10,28 +10,34 @@ export default defineConfig((env) => { const forgeEnv = env as ConfigEnv<'renderer'> const { root, mode } = forgeEnv - const viteConfig = { - build: { - outDir: `.vite/build/extension/`, - }, + const plugins = [ + react({ + jsxImportSource: '@emotion/react', + }), + tsconfigPaths(), + ] + + const build = { + outDir: `.vite/build/extension`, + } + + const viteConfig: InlineConfig = { + plugins, + build, } return { root, mode, base: './', - build: { - outDir: `.vite/build/extension/`, - }, + build, plugins: [ - react({ - jsxImportSource: '@emotion/react', - }), - tsconfigPaths(), + ...plugins, webExtension({ disableAutoLaunch: process.env.STANDALONE_EXTENSION !== 'true', htmlViteConfig: viteConfig, scriptViteConfig: viteConfig, + manifest: () => { return { name: 'k6 Studio', diff --git a/vite.main.config.ts b/vite.main.config.ts index 98b8f751..2d15cad5 100644 --- a/vite.main.config.ts +++ b/vite.main.config.ts @@ -1,5 +1,6 @@ import type { ConfigEnv, UserConfig } from 'vite' import { defineConfig, mergeConfig } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' import { getBuildConfig, getBuildDefine, @@ -23,7 +24,7 @@ export default defineConfig((env) => { external, }, }, - plugins: [pluginHotRestart('restart')], + plugins: [pluginHotRestart('restart'), tsconfigPaths()], define, resolve: { // Load the Node.js entry. diff --git a/vite.preload.config.ts b/vite.preload.config.ts index b28ddbd3..82e42027 100644 --- a/vite.preload.config.ts +++ b/vite.preload.config.ts @@ -1,6 +1,7 @@ import type { ConfigEnv, UserConfig } from 'vite' import { defineConfig, mergeConfig } from 'vite' import { getBuildConfig, external, pluginHotRestart } from './vite.base.config' +import tsconfigPaths from 'vite-tsconfig-paths' // https://vitejs.dev/config export default defineConfig((env) => { @@ -22,7 +23,7 @@ export default defineConfig((env) => { }, }, }, - plugins: [pluginHotRestart('reload')], + plugins: [pluginHotRestart('reload'), tsconfigPaths()], } return mergeConfig(getBuildConfig(forgeEnv), config)