diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 641edd30a3..cd47e91708 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" -import { useEditorContext } from "@tui/context/editor" +import { useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -84,6 +84,18 @@ function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } +function getEditorSelectionKey(selection: EditorSelection) { + return [ + selection.filePath, + selection.text, + selection.source ?? "", + selection.selection.start.line, + selection.selection.start.character, + selection.selection.end.line, + selection.selection.end.character, + ].join("-") +} + let stashed: { prompt: PromptInfo; cursor: number } | undefined export function Prompt(props: PromptProps) { @@ -135,6 +147,7 @@ export function Prompt(props: PromptProps) { if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) + let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) @@ -748,36 +761,38 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() const editorSelection = fileContextEnabled() ? editor.selection() : undefined - const editorParts = editorSelection - ? [ - { - id: PartID.ascending(), - type: "text" as const, - text: (() => { - const start = editorSelection.selection.start - const end = editorSelection.selection.end + const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined + const editorParts = + editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey + ? [ + { + id: PartID.ascending(), + type: "text" as const, + text: (() => { + const start = editorSelection.selection.start + const end = editorSelection.selection.end - let text = "" - if (start.line === end.line && start.character === end.character) { - text = `Note: The user opened the file "${editorSelection.filePath}".` - } else if (start.line === end.line) { - text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } else { - text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } + let text = "" + if (start.line === end.line && start.character === end.character) { + text = `Note: The user opened the file "${editorSelection.filePath}".` + } else if (start.line === end.line) { + text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` + } else { + text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` + } - return `${text} This may or may not be relevant to the current task.\n` - })(), - synthetic: true, - metadata: { - kind: "editor_context", - source: editorSelection.source ?? "editor", - filePath: editorSelection.filePath, - selection: editorSelection.selection, + return `${text} This may or may not be relevant to the current task.\n` + })(), + synthetic: true, + metadata: { + kind: "editor_context", + source: editorSelection.source ?? "editor", + filePath: editorSelection.filePath, + selection: editorSelection.selection, + }, }, - }, - ] - : [] + ] + : [] if (store.mode === "shell") { void sdk.client.session.shell({ @@ -840,7 +855,7 @@ export function Prompt(props: PromptProps) { ], }) .catch(() => {}) - editor.clearSelection() + lastSubmittedEditorSelectionKey = editorSelectionKey } history.append({ ...store.prompt, diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 531bf4507d..4ebc1c2c06 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -75,8 +75,9 @@ type EditorLockFile = { export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ name: "EditorContext", - init: () => { + init: (props: { WebSocketImpl?: typeof WebSocket }) => { const mentionListeners = new Set<(mention: EditorMention) => void>() + const WebSocketImpl = props.WebSocketImpl ?? WebSocket const [store, setStore] = createStore<{ status: "disabled" | "connecting" | "connected" selection: EditorSelection | undefined @@ -87,138 +88,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create server: undefined, }) - onMount(() => { - let socket: WebSocket | undefined - let closed = false - let reconnect: ReturnType | undefined - let attempt = 0 - let requestID = 0 - let zedSelection: Promise | undefined - let lastZedSelectionKey: string | undefined - const pending = new Map() + let socket: WebSocket | undefined + let closed = false + let reconnect: ReturnType | undefined + let attempt = 0 + let requestID = 0 + let zedSelection: Promise | undefined + let lastZedSelectionKey: string | undefined + let directory = process.cwd() + const pending = new Map() - const send = (payload: JsonRpcMessage) => { - if (!socket || socket.readyState !== WebSocket.OPEN) return - socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) + const send = (payload: JsonRpcMessage) => { + if (!socket || socket.readyState !== 1) return + socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) + } + + const request = (method: string, params?: unknown) => { + requestID += 1 + pending.set(requestID, method) + send({ id: requestID, method, params }) + } + + const connect = () => { + if (closed) return + + const connection = resolveEditorConnection(directory) + if (!connection) { + const dbPath = resolveZedDbPath() + if (!dbPath) { + setStore("status", "disabled") + scheduleReconnect() + return + } + zedSelection ??= resolveZedSelection(dbPath, directory) + .then((result) => { + if (closed || socket) return + if (result.type === "unavailable") return + const selection = result.type === "selection" ? result.selection : undefined + const key = editorSelectionKey(selection) + if (key !== lastZedSelectionKey) { + lastZedSelectionKey = key + setStore("selection", selection) + setStore("status", selection ? "connected" : "disabled") + } + }) + .catch(() => { + // Keep the last known Zed selection for transient polling failures. + }) + .finally(() => { + zedSelection = undefined + }) + scheduleZedPoll() + return } - const request = (method: string, params?: unknown) => { - requestID += 1 - pending.set(requestID, method) - send({ id: requestID, method, params }) - } + setStore("status", "connecting") + const current = openEditorSocket(connection, WebSocketImpl) + socket = current - const scheduleReconnect = () => { - if (closed) return - if (reconnect) clearTimeout(reconnect) - attempt += 1 - const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000) - reconnect = setTimeout(connect, delay) - } - - const scheduleZedPoll = () => { - if (closed) return - if (reconnect) clearTimeout(reconnect) - reconnect = setTimeout(connect, 1000) - } - - const connect = () => { - if (closed) return - - const connection = resolveEditorConnection() - if (!connection) { - const dbPath = resolveZedDbPath() - if (!dbPath) { - setStore("status", "disabled") - scheduleReconnect() - return - } - zedSelection ??= resolveZedSelection(dbPath) - .then((result) => { - if (closed || socket) return - if (result.type === "unavailable") return - const selection = result.type === "selection" ? result.selection : undefined - const key = editorSelectionKey(selection) - if (key !== lastZedSelectionKey) { - lastZedSelectionKey = key - setStore("selection", selection) - setStore("status", selection ? "connected" : "disabled") - } - }) - .catch(() => { - // Keep the last known Zed selection for transient polling failures. - }) - .finally(() => { - zedSelection = undefined - }) - scheduleZedPoll() + current.addEventListener("open", () => { + if (socket !== current) { + current.close() return } + attempt = 0 + setStore("status", "connected") + request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "opencode", version: "0.0.0" }, + }) + }) + + current.addEventListener("message", (event) => { + const message = parseMessage(event.data) + if (!message) return + + const selection = + message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined + if (selection?.success) { + setStore("selection", { ...selection.data, source: "websocket" }) + return + } + + const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined + if (mention?.success) { + mentionListeners.forEach((listener) => listener(mention.data)) + return + } + + if (typeof message.id !== "number") return + + const method = pending.get(message.id) + if (!method) return + + pending.delete(message.id) + if (message.error) return + + const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined + if (initialize?.success) { + setStore("server", initialize.data) + send({ method: "notifications/initialized" }) + return + } + }) + + current.addEventListener("close", () => { + if (socket !== current) return + + socket = undefined + pending.clear() + if (closed) return + setStore("status", "connecting") - const current = openEditorSocket(connection) - socket = current + scheduleReconnect() + }) + } - current.addEventListener("open", () => { - if (socket !== current) { - current.close() - return - } + const scheduleReconnect = () => { + if (closed) return + if (reconnect) clearTimeout(reconnect) + attempt += 1 + const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000) + reconnect = setTimeout(connect, delay) + } - attempt = 0 - setStore("status", "connected") - request("initialize", { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "opencode", version: "0.0.0" }, - }) - }) + const scheduleZedPoll = () => { + if (closed) return + if (reconnect) clearTimeout(reconnect) + reconnect = setTimeout(connect, 1000) + } - current.addEventListener("message", (event) => { - const message = parseMessage(event.data) - if (!message) return + const reconnectWithDirectory = (nextDirectory?: string) => { + const resolved = nextDirectory || process.cwd() + if (directory === resolved) return - const selection = - message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined - if (selection?.success) { - setStore("selection", { ...selection.data, source: "websocket" }) - return - } - - const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined - if (mention?.success) { - mentionListeners.forEach((listener) => listener(mention.data)) - return - } - - if (typeof message.id !== "number") return - - const method = pending.get(message.id) - if (!method) return - - pending.delete(message.id) - if (message.error) return - - const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined - if (initialize?.success) { - setStore("server", initialize.data) - send({ method: "notifications/initialized" }) - return - } - }) - - current.addEventListener("close", () => { - if (socket !== current) return - - socket = undefined - pending.clear() - if (closed) return - - setStore("status", "connecting") - scheduleReconnect() - }) + directory = resolved + attempt = 0 + pending.clear() + lastZedSelectionKey = undefined + if (reconnect) clearTimeout(reconnect) + reconnect = undefined + if (socket) { + const current = socket + socket = undefined + current.close() } + setStore("status", "disabled") + setStore("selection", undefined) + setStore("server", undefined) + connect() + } + onMount(() => { connect() onCleanup(() => { @@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return { enabled() { - return Boolean(resolveEditorConnection() || resolveZedDbPath()) + return Boolean(resolveEditorConnection(directory) || resolveZedDbPath()) }, connected() { return store.status === "connected" @@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create server() { return store.server }, + reconnect(directory?: string) { + setStore("selection", undefined) + reconnectWithDirectory(directory) + }, } }, }) @@ -260,8 +287,16 @@ function parsePort(value: string | undefined) { return parsed } -function resolveEditorConnection(): EditorConnection | undefined { - const lock = resolveEditorLockFile() +function resolveEditorConnection(directory: string): EditorConnection | undefined { + const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT) + if (port) { + return { + url: `ws://127.0.0.1:${port}`, + source: `env:${port}`, + } + } + + const lock = resolveEditorLockFile(directory) if (lock) { return { url: `ws://127.0.0.1:${lock.port}`, @@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined { source: `lock:${lock.port}`, } } - - const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT) - if (!port) return - return { - url: `ws://127.0.0.1:${port}`, - source: `env:${port}`, - } } -function resolveEditorLockFile() { +function resolveEditorLockFile(activeDirectory: string) { const directory = path.join(os.homedir(), ".claude", "ide") let entries: string[] @@ -288,10 +316,9 @@ function resolveEditorLockFile() { return } - const cwd = process.cwd() - // longest workspace folder that contains cwd; 0 if none match + // longest workspace folder that contains the active session directory; 0 if none match const bestMatchLength = (lock: EditorLockFile) => - Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd))) + Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory))) const locks = entries .filter((entry) => entry.endsWith(".lock")) .map((entry) => readEditorLockFile(path.join(directory, entry))) @@ -343,10 +370,10 @@ function pathContainsLength(parent: string, child: string) { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0 } -function openEditorSocket(connection: EditorConnection) { - if (!connection.authToken) return new WebSocket(connection.url) +function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) { + if (!connection.authToken) return new WebSocketImpl(connection.url) - return new WebSocket(connection.url, { + return new WebSocketImpl(connection.url, { headers: { "x-claude-code-ide-authorization": connection.authToken, }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 60343de496..8855338d1d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -50,6 +50,7 @@ import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" +import { useEditorContext } from "@tui/context/editor" import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" @@ -179,6 +180,7 @@ export function Session() { const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const toast = useToast() const sdk = useSDK() + const editor = useEditorContext() createEffect(() => { const sessionID = route.sessionID @@ -206,6 +208,7 @@ export function Session() { await sync.bootstrap({ fatal: false }) } catch {} } + editor.reconnect(result.data.directory) await sync.session.sync(sessionID) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { diff --git a/packages/opencode/test/cli/tui/editor-context.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts similarity index 100% rename from packages/opencode/test/cli/tui/editor-context.test.ts rename to packages/opencode/test/cli/tui/editor-context-zed.test.ts diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx new file mode 100644 index 0000000000..e896c29fb5 --- /dev/null +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -0,0 +1,224 @@ +import { mkdir, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, expect, spyOn, test } from "bun:test" +import { createRoot } from "solid-js" +import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor" +import { tmpdir } from "../../fixture/fixture" +import { FakeWebSocket } from "../../lib/websocket" + +const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT +const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT + +afterEach(() => { + process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort + process.env.OPENCODE_EDITOR_SSE_PORT = originalOpencodePort +}) + +function nextTick() { + return new Promise((resolve) => queueMicrotask(resolve)) +} + +function mountEditorContext(WebSocketImpl?: typeof WebSocket) { + let editor!: ReturnType + let dispose!: () => void + + createRoot((nextDispose) => { + dispose = nextDispose + + const Consumer = () => { + editor = useEditorContext() + return null + } + + return ( + + + + ) + }) + + return { + editor, + dispose, + } +} + +function createWebSocketImpl(...sockets: FakeWebSocket[]) { + let index = 0 + + return class { + constructor(url: string, options?: { headers?: Record }) { + const socket = sockets[index] + index += 1 + expect(socket).toBeDefined() + expect(url).toBe(socket!.url) + expect(options).toEqual(socket!.options) + return socket as unknown as object + } + } as unknown as typeof WebSocket +} + +test("useEditorContext reconnect switches editor server by session directory", async () => { + await using tmp = await tmpdir() + const startupDirectory = path.join(tmp.path, "startup") + const sessionDirectory = path.join(tmp.path, "session") + const ideDirectory = path.join(tmp.path, ".claude", "ide") + await mkdir(startupDirectory, { recursive: true }) + await mkdir(sessionDirectory, { recursive: true }) + await mkdir(ideDirectory, { recursive: true }) + await writeFile( + path.join(ideDirectory, "3001.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [startupDirectory], + }), + ) + await writeFile( + path.join(ideDirectory, "3002.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [sessionDirectory], + }), + ) + + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.OPENCODE_EDITOR_SSE_PORT = undefined + spyOn(process, "cwd").mockImplementation(() => startupDirectory) + spyOn(os, "homedir").mockImplementation(() => tmp.path) + const firstSocket = new FakeWebSocket("ws://127.0.0.1:3001") + const secondSocket = new FakeWebSocket("ws://127.0.0.1:3002") + + const mounted = mountEditorContext(createWebSocketImpl(firstSocket, secondSocket)) + await nextTick() + + expect(firstSocket.closed).toBeFalse() + + mounted.editor.reconnect(sessionDirectory) + await nextTick() + + expect(firstSocket.closed).toBeTrue() + expect(secondSocket.closed).toBeFalse() + + mounted.dispose() +}) + +test("useEditorContext favors configured port over lock files", async () => { + await using tmp = await tmpdir() + const startupDirectory = path.join(tmp.path, "startup") + const ideDirectory = path.join(tmp.path, ".claude", "ide") + await mkdir(startupDirectory, { recursive: true }) + await mkdir(ideDirectory, { recursive: true }) + await writeFile( + path.join(ideDirectory, "3001.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [startupDirectory], + }), + ) + + process.env.CLAUDE_CODE_SSE_PORT = "4010" + process.env.OPENCODE_EDITOR_SSE_PORT = undefined + spyOn(process, "cwd").mockImplementation(() => startupDirectory) + spyOn(os, "homedir").mockImplementation(() => tmp.path) + const socket = new FakeWebSocket("ws://127.0.0.1:4010") + + const mounted = mountEditorContext(createWebSocketImpl(socket)) + await nextTick() + + expect(socket.closed).toBeFalse() + + mounted.dispose() +}) + +test("useEditorContext resets selection when reconnecting", async () => { + await using tmp = await tmpdir() + const startupDirectory = path.join(tmp.path, "startup") + const ideDirectory = path.join(tmp.path, ".claude", "ide") + await mkdir(startupDirectory, { recursive: true }) + await mkdir(ideDirectory, { recursive: true }) + await writeFile( + path.join(ideDirectory, "3001.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [startupDirectory], + }), + ) + + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.OPENCODE_EDITOR_SSE_PORT = undefined + spyOn(process, "cwd").mockImplementation(() => startupDirectory) + spyOn(os, "homedir").mockImplementation(() => tmp.path) + const socket = new FakeWebSocket("ws://127.0.0.1:3001") + + const mounted = mountEditorContext(createWebSocketImpl(socket)) + await nextTick() + + expect(socket.closed).toBeFalse() + expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.connected()).toBeFalse() + + socket.open() + socket.message( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: "2025-11-25", + serverInfo: { name: "test", version: "0.0.0" }, + }, + }), + ) + socket.message( + JSON.stringify({ + jsonrpc: "2.0", + method: "selection_changed", + params: { + text: "foo", + filePath: path.join(startupDirectory, "file.ts"), + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + }), + ) + + expect(mounted.editor.connected()).toBeTrue() + expect(mounted.editor.server()).toEqual({ + protocolVersion: "2025-11-25", + serverInfo: { name: "test", version: "0.0.0" }, + }) + expect(mounted.editor.selection()).toEqual({ + text: "foo", + filePath: path.join(startupDirectory, "file.ts"), + source: "websocket", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }) + + mounted.editor.reconnect(startupDirectory) + + expect(socket.closed).toBeFalse() + expect(mounted.editor.connected()).toBeTrue() + expect(mounted.editor.selection()).toBeUndefined() + + mounted.dispose() +}) + +test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => { + await using tmp = await tmpdir() + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.OPENCODE_EDITOR_SSE_PORT = "4020" + spyOn(process, "cwd").mockImplementation(() => tmp.path) + const socket = new FakeWebSocket("ws://127.0.0.1:4020") + + const mounted = mountEditorContext(createWebSocketImpl(socket)) + await nextTick() + + expect(socket.closed).toBeFalse() + + mounted.dispose() +}) diff --git a/packages/opencode/test/lib/websocket.ts b/packages/opencode/test/lib/websocket.ts new file mode 100644 index 0000000000..7f7d7fba8c --- /dev/null +++ b/packages/opencode/test/lib/websocket.ts @@ -0,0 +1,46 @@ +export class FakeWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readyState = FakeWebSocket.CONNECTING + closed = false + sent: string[] = [] + listeners = new Map void>>() + + constructor( + readonly url: string, + readonly options?: { headers?: Record }, + ) {} + + addEventListener(type: string, listener: (event: { data?: unknown }) => void) { + const current = this.listeners.get(type) ?? new Set<(event: { data?: unknown }) => void>() + current.add(listener) + this.listeners.set(type, current) + } + + send(data: string) { + this.sent.push(data) + } + + close() { + if (this.readyState === FakeWebSocket.CLOSED) return + this.closed = true + this.readyState = FakeWebSocket.CLOSED + this.emit("close", {}) + } + + open() { + this.readyState = FakeWebSocket.OPEN + this.emit("open", {}) + } + + message(data: unknown) { + this.emit("message", { data }) + } + + emit(type: string, event: { data?: unknown }) { + this.listeners.get(type)?.forEach((listener) => listener(event)) + } +}