diff --git a/packages/cli/src/lib/driver/commands/network.ts b/packages/cli/src/lib/driver/commands/network.ts index 5887f132c..9e52c0f72 100644 --- a/packages/cli/src/lib/driver/commands/network.ts +++ b/packages/cli/src/lib/driver/commands/network.ts @@ -3,7 +3,8 @@ import type { DriverCommandHandlers } from "./types.js"; export const networkHandlers: DriverCommandHandlers = { async "network.on"(manager) { const page = await manager.activePage(); - return manager.network.enable(page); + const websocketUrl = await manager.networkWebSocketDebuggerUrl(); + return manager.network.enable(page, websocketUrl); }, async "network.off"(manager) { diff --git a/packages/cli/src/lib/driver/network-capture.ts b/packages/cli/src/lib/driver/network-capture.ts index 921f58afb..038802225 100644 --- a/packages/cli/src/lib/driver/network-capture.ts +++ b/packages/cli/src/lib/driver/network-capture.ts @@ -7,7 +7,10 @@ import { getNetworkDir, writePrivateFile, } from "./daemon/paths.js"; -import { DriverError } from "./errors.js"; +import { + NetworkCdpSidecar, + type NetworkCdpSession, +} from "./network-cdp-sidecar.js"; interface PendingRequest { body: string | null; @@ -26,37 +29,40 @@ interface ResponseMetadata { statusText: string; } -type CdpSession = { - off?: (event: string, listener: (...args: unknown[]) => void) => void; - on: (event: string, listener: (...args: unknown[]) => void) => void; - send: ( - method: string, - params?: Record, - ) => Promise; +type StagehandV4Page = { + pageId: string; }; +/** + * The V3 Browse network writer, adapted only at the CDP-session boundary. + * Keeping request correlation and the on-disk request/response schema here + * unchanged gives the V4 CLI observable parity without adding a public + * Stagehand network-event API. + */ export class NetworkCapture { - private cdpSession: CdpSession | null = null; + private cdpSession: NetworkCdpSession | null = null; private counter = 0; private enabled = false; private readonly pendingRequests = new Map(); private readonly requestDirs = new Map>(); private readonly requestStartTimes = new Map(); private readonly responseMetadata = new Map(); - private readonly listeners: Array<[string, (...args: unknown[]) => void]> = - []; + private readonly listeners: Array<[string, (params: unknown) => void]> = []; private networkDir: string | null = null; - constructor(private readonly session: string) {} + constructor( + private readonly session: string, + private readonly sidecar = new NetworkCdpSidecar(), + ) {} async enable( - page: unknown, + page: StagehandV4Page, + browserWebSocketDebuggerUrl: string, ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { - if (this.enabled && this.networkDir) { + if (this.enabled && this.networkDir && this.cdpSession?.connected) { return { alreadyEnabled: true, enabled: true, path: this.networkDir }; } - - const cdpSession = await this.networkCdpSession(page); + if (this.enabled) await this.disable(); await ensureRuntimeDir(); this.networkDir = getNetworkDir(this.session); @@ -67,24 +73,34 @@ export class NetworkCapture { this.requestStartTimes.clear(); this.responseMetadata.clear(); + const cdpSession = await this.sidecar.attach( + browserWebSocketDebuggerUrl, + page.pageId, + ); this.cdpSession = cdpSession; - await cdpSession.send("Network.enable", { - maxResourceBufferSize: 5_000_000, - maxTotalBufferSize: 10_000_000, - }); + try { + await cdpSession.send("Network.enable", { + maxResourceBufferSize: 5_000_000, + maxTotalBufferSize: 10_000_000, + }); - this.addListener("Network.requestWillBeSent", (params) => { - void this.handleRequestWillBeSent(params); - }); - this.addListener("Network.responseReceived", (params) => { - this.handleResponseReceived(params); - }); - this.addListener("Network.loadingFinished", (params) => { - void this.handleLoadingFinished(params); - }); - this.addListener("Network.loadingFailed", (params) => { - void this.handleLoadingFailed(params); - }); + this.addListener("Network.requestWillBeSent", (params) => { + void this.handleRequestWillBeSent(params); + }); + this.addListener("Network.responseReceived", (params) => { + this.handleResponseReceived(params); + }); + this.addListener("Network.loadingFinished", (params) => { + void this.handleLoadingFinished(params); + }); + this.addListener("Network.loadingFailed", (params) => { + void this.handleLoadingFailed(params); + }); + } catch (error) { + this.cdpSession = null; + await cdpSession.detach().catch(() => undefined); + throw error; + } this.enabled = true; return { enabled: true, path: this.networkDir }; @@ -99,20 +115,27 @@ export class NetworkCapture { return { alreadyDisabled: true, enabled: false, path: this.networkDir }; } + const cdpSession = this.cdpSession; + this.enabled = false; for (const [event, listener] of this.listeners) { - this.cdpSession?.off?.(event, listener); + cdpSession?.off(event, listener); } this.listeners.length = 0; - await this.cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.detach().catch(() => undefined); this.cdpSession = null; - this.enabled = false; return { enabled: false, path: this.networkDir }; } + async close(): Promise { + await this.disable().catch(() => undefined); + this.sidecar.close(); + } + path(): { enabled: boolean; path: string } { return { - enabled: this.enabled, + enabled: this.enabled && (this.cdpSession?.connected ?? false), path: this.networkDir ?? getNetworkDir(this.session), }; } @@ -146,20 +169,12 @@ export class NetworkCapture { private addListener( event: string, - listener: (...args: unknown[]) => void, + listener: (params: unknown) => void, ): void { this.cdpSession?.on(event, listener); this.listeners.push([event, listener]); } - private async networkCdpSession(page: unknown): Promise { - void page; - throw new DriverError( - "Network capture is not available in this Stagehand V4 runtime. Apply the CLI CDP sidecar fast-follow to restore `browse network on`.", - { code: "network_capture_unavailable" }, - ); - } - private handleRequestWillBeSent(params: unknown): void { if (!this.enabled || !this.networkDir) return; const event = params as { diff --git a/packages/cli/src/lib/driver/network-cdp-sidecar.ts b/packages/cli/src/lib/driver/network-cdp-sidecar.ts new file mode 100644 index 000000000..76b4ce472 --- /dev/null +++ b/packages/cli/src/lib/driver/network-cdp-sidecar.ts @@ -0,0 +1,307 @@ +import WebSocket from "ws"; + +import { DriverError } from "./errors.js"; + +type CdpEventListener = (params: unknown) => void; + +interface CdpMessage { + error?: { code: number; message: string }; + id?: number; + method?: string; + params?: unknown; + result?: unknown; + sessionId?: string; +} + +interface PendingCommand { + method: string; + reject: (error: Error) => void; + resolve: (result: unknown) => void; +} + +export interface NetworkCdpSession { + readonly connected: boolean; + detach(): Promise; + off(event: string, listener: CdpEventListener): void; + on(event: string, listener: CdpEventListener): void; + send( + method: string, + params?: Record, + ): Promise; +} + +export type NetworkCdpWebSocketFactory = (url: string) => WebSocket; + +/** + * A CLI-owned browser-level CDP connection used only for V3-compatible + * network capture. The connection is intentionally kept alive across + * `network off`/`network on`: Browserbase treats closing an auxiliary browser + * WebSocket as a browser-session disconnect. It is closed only with the Browse + * driver session. + */ +export class NetworkCdpSidecar { + private connecting: Promise | null = null; + private nextId = 1; + private readonly pending = new Map(); + private readonly sessions = new Map< + string, + Map> + >(); + private socket: WebSocket | null = null; + private websocketUrl: string | null = null; + + constructor( + private readonly createWebSocket: NetworkCdpWebSocketFactory = (url) => + new WebSocket(url), + ) {} + + async attach( + websocketUrl: string, + targetId: string, + ): Promise { + await this.ensureConnected(websocketUrl); + const { sessionId } = await this.sendCommand<{ sessionId: string }>( + "Target.attachToTarget", + { flatten: true, targetId }, + ); + this.sessions.set(sessionId, new Map()); + return new AttachedNetworkCdpSession(this, sessionId); + } + + close(): void { + const socket = this.socket; + this.disconnect( + socket, + new DriverError("Network capture CDP sidecar closed.", { + code: "network_sidecar_closed", + }), + ); + if ( + socket && + (socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING) + ) { + socket.close(); + } + } + + hasSession(sessionId: string): boolean { + return ( + this.socket?.readyState === WebSocket.OPEN && this.sessions.has(sessionId) + ); + } + + on(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + if (!listeners) return; + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(listener); + listeners.set(event, eventListeners); + } + + off(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + const eventListeners = listeners?.get(event); + eventListeners?.delete(listener); + if (eventListeners?.size === 0) listeners?.delete(event); + } + + async sendToSession( + sessionId: string, + method: string, + params: Record = {}, + ): Promise { + if (!this.sessions.has(sessionId)) { + throw new DriverError("Network capture CDP session is detached.", { + code: "network_sidecar_detached", + }); + } + return this.sendCommand(method, params, sessionId); + } + + async detach(sessionId: string): Promise { + if (!this.sessions.has(sessionId)) return; + this.sessions.delete(sessionId); + await this.sendCommand("Target.detachFromTarget", { sessionId }).catch( + () => undefined, + ); + } + + private async ensureConnected(websocketUrl: string): Promise { + if (this.socket?.readyState === WebSocket.OPEN) { + if (this.websocketUrl !== websocketUrl) { + throw new DriverError( + "The browser CDP endpoint changed during the Browse session.", + { code: "network_sidecar_endpoint_changed" }, + ); + } + return; + } + if (this.connecting) { + await this.connecting; + return; + } + + const connecting = this.open(websocketUrl); + this.connecting = connecting; + try { + await connecting; + } finally { + if (this.connecting === connecting) this.connecting = null; + } + } + + private async open(websocketUrl: string): Promise { + let socket: WebSocket; + try { + socket = this.createWebSocket(websocketUrl); + } catch (error) { + throw connectionError(error); + } + this.socket = socket; + this.websocketUrl = websocketUrl; + + await new Promise((resolve, reject) => { + let opened = false; + socket.on("message", (raw: WebSocket.RawData) => { + this.handleMessage(socket, raw); + }); + socket.on("error", (error: Error) => { + const failure = connectionError(error); + this.disconnect(socket, failure); + if (!opened) reject(failure); + }); + socket.on("close", () => { + const error = new DriverError( + "Network capture CDP sidecar disconnected.", + { code: "network_sidecar_disconnected" }, + ); + this.disconnect(socket, error); + if (!opened) reject(connectionError(error)); + }); + socket.once("open", () => { + opened = true; + resolve(); + }); + }); + } + + private sendCommand( + method: string, + params: Record = {}, + sessionId?: string, + ): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return Promise.reject( + new DriverError("Network capture CDP sidecar is not connected.", { + code: "network_sidecar_disconnected", + }), + ); + } + + const id = this.nextId++; + const message = sessionId + ? { id, method, params, sessionId } + : { id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { + method, + reject, + resolve: (result) => resolve(result as T), + }); + try { + socket.send(JSON.stringify(message)); + } catch (error) { + this.pending.delete(id); + reject(commandError(method, error)); + } + }); + } + + private handleMessage(socket: WebSocket, raw: WebSocket.RawData): void { + if (this.socket !== socket) return; + let message: CdpMessage; + try { + message = JSON.parse(raw.toString()) as CdpMessage; + } catch { + return; + } + + if (message.id !== undefined) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(commandError(pending.method, message.error.message)); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method === "Target.detachedFromTarget") { + const detachedSessionId = (message.params as { sessionId?: string }) + ?.sessionId; + if (detachedSessionId) this.sessions.delete(detachedSessionId); + return; + } + + if (!message.sessionId || !message.method) return; + const listeners = this.sessions.get(message.sessionId)?.get(message.method); + for (const listener of listeners ?? []) listener(message.params); + } + + private disconnect(socket: WebSocket | null, error: Error): void { + if (!socket || this.socket !== socket) return; + this.socket = null; + this.websocketUrl = null; + this.sessions.clear(); + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } +} + +class AttachedNetworkCdpSession implements NetworkCdpSession { + constructor( + private readonly sidecar: NetworkCdpSidecar, + private readonly sessionId: string, + ) {} + + get connected(): boolean { + return this.sidecar.hasSession(this.sessionId); + } + + detach(): Promise { + return this.sidecar.detach(this.sessionId); + } + + off(event: string, listener: CdpEventListener): void { + this.sidecar.off(this.sessionId, event, listener); + } + + on(event: string, listener: CdpEventListener): void { + this.sidecar.on(this.sessionId, event, listener); + } + + send( + method: string, + params?: Record, + ): Promise { + return this.sidecar.sendToSession(this.sessionId, method, params); + } +} + +function connectionError(cause: unknown): DriverError { + return new DriverError("Failed to connect the network capture CDP sidecar.", { + cause, + code: "network_sidecar_connect_failed", + }); +} + +function commandError(method: string, cause: unknown): DriverError { + return new DriverError(`Network capture CDP command ${method} failed.`, { + cause, + code: "network_sidecar_command_failed", + }); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 96989b4f2..6bd6af739 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -159,6 +159,18 @@ export class DriverSessionManager { return this.stagehand; } + async networkWebSocketDebuggerUrl(): Promise { + const stagehand = await this.stagehandInstance(); + const websocketUrl = stagehand.rpcClient?.browserWebSocketDebuggerUrl; + if (!websocketUrl) { + throw new DriverError( + "Stagehand did not expose the browser CDP endpoint required for network capture.", + { code: "network_sidecar_endpoint_unavailable" }, + ); + } + return websocketUrl; + } + async status(): Promise { if (!this.stagehand || !this.context) { return { @@ -209,7 +221,7 @@ export class DriverSessionManager { this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; - await this.network.disable().catch(() => undefined); + await this.network.close().catch(() => undefined); if (stagehand) { await stagehand.close().catch(() => undefined); } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 013c1c820..45faee22c 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -427,27 +427,27 @@ describe("driver commands", () => { } }); - it("reports the isolated V4 network-capture gap and installs the CLI-owned cursor overlay", async () => { + it("enables sidecar network capture and installs the CLI-owned cursor overlay", async () => { const page = { evaluate: vi.fn() }; const network = { - enable: vi.fn(async () => { - throw new Error("Network capture is not available"); - }), + enable: vi.fn(async () => ({ enabled: true, path: "/tmp/network" })), }; const manager = { activePage: vi.fn(async () => page), network, + networkWebSocketDebuggerUrl: vi.fn(async () => "ws://sidecar.test"), } as unknown as Parameters< NonNullable<(typeof networkHandlers)["network.on"]> >[0]; - await expect(networkHandlers["network.on"]!(manager, {})).rejects.toThrow( - "Network capture is not available", - ); + await expect(networkHandlers["network.on"]!(manager, {})).resolves.toEqual({ + enabled: true, + path: "/tmp/network", + }); await expect(runtimeHandlers.cursor!(manager, {})).resolves.toEqual({ enabled: true, }); - expect(network.enable).toHaveBeenCalledWith(page); + expect(network.enable).toHaveBeenCalledWith(page, "ws://sidecar.test"); expect(page.evaluate).toHaveBeenCalledOnce(); const cursorInstaller = page.evaluate.mock.calls[0]?.[0]; expect(cursorInstaller).toEqual(expect.any(String)); diff --git a/packages/cli/tests/network-capture.test.ts b/packages/cli/tests/network-capture.test.ts new file mode 100644 index 000000000..147ef8ad9 --- /dev/null +++ b/packages/cli/tests/network-capture.test.ts @@ -0,0 +1,184 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + NetworkCdpSession, + NetworkCdpSidecar, +} from "../src/lib/driver/network-cdp-sidecar.js"; +import { NetworkCapture } from "../src/lib/driver/network-capture.js"; + +const cleanupPaths: string[] = []; + +afterEach(async () => { + while (cleanupPaths.length > 0) { + const cleanupPath = cleanupPaths.pop(); + if (cleanupPath) { + await fs.rm(cleanupPath, { force: true, recursive: true }); + } + } +}); + +describe("NetworkCapture", () => { + it("preserves the V3 files and keeps its sidecar alive across on/off/on", async () => { + const daemonDir = await fs.mkdtemp( + join(tmpdir(), "browse-network-sidecar-"), + ); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = new FakeCdpSession(); + const sidecar = { + attach: vi.fn(async () => session), + close: vi.fn(), + }; + const capture = new NetworkCapture( + "sidecar", + sidecar as unknown as NetworkCdpSidecar, + ); + const originalWriteFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (...args) => { + if (String(args[0]).endsWith("request.json")) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return originalWriteFile(...args); + }); + + try { + await expect( + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + ).resolves.toMatchObject({ enabled: true }); + expect(sidecar.attach).toHaveBeenCalledWith( + "ws://browser.test", + "page-1", + ); + + session.emit("Network.requestWillBeSent", { + request: { + headers: { accept: "text/plain" }, + method: "POST", + postData: "hello=world", + url: "https://example.com/fast", + }, + requestId: "request-1", + type: "Fetch", + }); + session.emit("Network.responseReceived", { + requestId: "request-1", + response: { + headers: { "content-type": "text/plain" }, + mimeType: "text/plain", + status: 200, + statusText: "OK", + }, + }); + session.emit("Network.loadingFinished", { requestId: "request-1" }); + + const requestDir = join( + daemonDir, + "sidecar-network", + "000-POST-example.com-fast", + ); + const responsePath = join(requestDir, "response.json"); + await waitForFile(responsePath); + await expect( + readJson(join(requestDir, "request.json")), + ).resolves.toMatchObject({ + body: "hello=world", + method: "POST", + resourceType: "Fetch", + url: "https://example.com/fast", + }); + await expect(readJson(responsePath)).resolves.toMatchObject({ + body: "ok", + mimeType: "text/plain", + status: 200, + statusText: "OK", + }); + + await expect(capture.disable()).resolves.toMatchObject({ + enabled: false, + }); + expect(session.detach).toHaveBeenCalledOnce(); + expect(sidecar.close).not.toHaveBeenCalled(); + + session.connected = true; + await capture.enable({ pageId: "page-2" }, "ws://browser.test"); + expect(sidecar.attach).toHaveBeenLastCalledWith( + "ws://browser.test", + "page-2", + ); + expect(sidecar.attach).toHaveBeenCalledTimes(2); + + await capture.close(); + expect(sidecar.close).toHaveBeenCalledOnce(); + } finally { + writeFileSpy.mockRestore(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } + }); +}); + +class FakeCdpSession implements NetworkCdpSession { + connected = true; + readonly detach = vi.fn(async () => { + this.connected = false; + }); + private readonly listeners = new Map< + string, + Set<(params: unknown) => void> + >(); + + async send(method: string): Promise { + if (method === "Network.getResponseBody") { + return { body: "ok" } as T; + } + return {} as T; + } + + on(event: string, listener: (params: unknown) => void): void { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + } + + off(event: string, listener: (params: unknown) => void): void { + this.listeners.get(event)?.delete(listener); + } + + emit(event: string, params: unknown): void { + for (const listener of this.listeners.get(event) ?? []) listener(params); + } +} + +async function readJson(filePath: string): Promise> { + return JSON.parse(await fs.readFile(filePath, "utf8")) as Record< + string, + unknown + >; +} + +async function waitForFile(filePath: string): Promise { + const start = Date.now(); + while (Date.now() - start < 1_000) { + try { + await fs.access(filePath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${filePath}`); +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} diff --git a/packages/cli/tests/network-cdp-sidecar.test.ts b/packages/cli/tests/network-cdp-sidecar.test.ts new file mode 100644 index 000000000..5692183d2 --- /dev/null +++ b/packages/cli/tests/network-cdp-sidecar.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; + +import type WebSocket from "ws"; +import { describe, expect, it, vi } from "vitest"; + +import { NetworkCdpSidecar } from "../src/lib/driver/network-cdp-sidecar.js"; + +describe("NetworkCdpSidecar", () => { + it("reuses one browser WebSocket across detached page sessions", async () => { + const socket = new FakeWebSocket(); + const factory = vi.fn(() => { + queueMicrotask(() => socket.open()); + return socket as unknown as WebSocket; + }); + const sidecar = new NetworkCdpSidecar(factory); + + const first = await sidecar.attach("ws://browser.test", "page-1"); + const listener = vi.fn(); + first.on("Network.requestWillBeSent", listener); + socket.emitCdp({ + method: "Network.requestWillBeSent", + params: { requestId: "request-1" }, + sessionId: "sidecar-session-1", + }); + + expect(listener).toHaveBeenCalledWith({ requestId: "request-1" }); + await expect(first.send("Network.enable")).resolves.toEqual({}); + await first.detach(); + expect(first.connected).toBe(false); + + const second = await sidecar.attach("ws://browser.test", "page-2"); + expect(second.connected).toBe(true); + expect(factory).toHaveBeenCalledOnce(); + expect(socket.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-1" }, + }), + expect.objectContaining({ + method: "Target.detachFromTarget", + params: { sessionId: "sidecar-session-1" }, + }), + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-2" }, + }), + ]), + ); + + sidecar.close(); + expect(socket.closed).toBe(true); + }); +}); + +class FakeWebSocket extends EventEmitter { + closed = false; + readyState = 0; + readonly sent: Array<{ + id: number; + method: string; + params: Record; + sessionId?: string; + }> = []; + private sessionCounter = 0; + + open(): void { + this.readyState = 1; + this.emit("open"); + } + + send(raw: string): void { + const message = JSON.parse(raw) as (typeof this.sent)[number]; + this.sent.push(message); + const result = + message.method === "Target.attachToTarget" + ? { sessionId: `sidecar-session-${++this.sessionCounter}` } + : {}; + queueMicrotask(() => this.emitCdp({ id: message.id, result })); + } + + close(): void { + this.closed = true; + this.readyState = 3; + this.emit("close"); + } + + emitCdp(message: Record): void { + this.emit("message", Buffer.from(JSON.stringify(message))); + } +}