From 2f9c20519bef756ebc4ac5407ba6dad5dbabee7a Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 09:53:51 +0100 Subject: [PATCH 1/7] Polish CLI init: handle plugin replacement and fix formatting - Add CLOSE_CODE_REPLACED for cleanly handling when a new plugin tab replaces the active one - Add status message when initializing git repository - Fix package.json indentation to use 4 spaces instead of 2 (matches prettier config) - Add 'replaced' mode to handle plugin connection replacement state - Stop reconnecting when a plugin is replaced by another tab --- packages/code-link-cli/src/helpers/connection.ts | 5 ++++- packages/code-link-cli/src/helpers/git.ts | 3 ++- packages/code-link-cli/src/helpers/installer.ts | 2 +- packages/code-link-cli/src/utils/project.ts | 2 +- packages/code-link-shared/src/types.ts | 2 +- plugins/code-link/src/App.tsx | 6 ++++++ plugins/code-link/src/utils/sockets.ts | 16 +++++++++++++++- 7 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/code-link-cli/src/helpers/connection.ts b/packages/code-link-cli/src/helpers/connection.ts index fe63730d9..d00ab9819 100644 --- a/packages/code-link-cli/src/helpers/connection.ts +++ b/packages/code-link-cli/src/helpers/connection.ts @@ -10,6 +10,9 @@ import { WebSocket, WebSocketServer } from "ws" import type { CertBundle } from "./certs.ts" import { debug, error, info } from "../utils/logging.ts" +/** Custom close code sent when a new plugin tab replaces the active one. */ +export const CLOSE_CODE_REPLACED = 4001 + export interface ConnectionCallbacks { onHandshake: (client: WebSocket, message: { projectId: string; projectName: string }) => void onMessage: (message: PluginToCliMessage) => void @@ -89,7 +92,7 @@ export function initConnection(port: number, certs: CertBundle): Promise { dispatch({ type: "set-mode", mode: "syncing" }) } + const handleReplaced = () => { + dispatch({ type: "set-mode", mode: "replaced" }) + } const handleMessage = createMessageHandler({ dispatch, api, syncTracker }) const controller = createSocketConnectionController({ @@ -175,6 +178,7 @@ export function App() { onMessage: handleMessage, onConnected: handleConnected, onDisconnected: handleDisconnected, + onReplaced: handleReplaced, }) controller.start() @@ -283,6 +287,8 @@ function backgroundStatusFromMode(mode: Mode | undefined): string | null { return null case "idle": return "Watching Files…" + case "replaced": + return "Replaced by another Plugin connection" default: return "Loading…" } diff --git a/plugins/code-link/src/utils/sockets.ts b/plugins/code-link/src/utils/sockets.ts index ad4f7d062..448265850 100644 --- a/plugins/code-link/src/utils/sockets.ts +++ b/plugins/code-link/src/utils/sockets.ts @@ -24,18 +24,23 @@ export interface SocketConnectionController { stop: () => void } +/** Custom close code sent by CLI when another plugin tab takes over. */ +const CLOSE_CODE_REPLACED = 4001 + export function createSocketConnectionController({ project, setSocket, onMessage, onConnected, onDisconnected, + onReplaced, }: { project: ProjectInfo setSocket: (socket: WebSocket | null) => void onMessage: (message: CliToPluginMessage, socket: WebSocket) => Promise onConnected: () => void onDisconnected: (message: string) => void + onReplaced: () => void }): SocketConnectionController { const RECONNECT_BASE_MS = 500 const RECONNECT_MAX_MS = 5000 @@ -306,7 +311,6 @@ export function createSocketConnectionController({ if (isStale()) return setActiveSocket(null) - failureCount += 1 log.debug("WebSocket closed", { code: event.code, @@ -319,6 +323,16 @@ export function createSocketConnectionController({ failureCount, }) + // Another plugin tab took over this connection — stop reconnecting. + if (event.code === CLOSE_CODE_REPLACED) { + log.debug("Connection replaced by another plugin tab, disposing") + onReplaced() + setLifecycle("disposed") + return + } + + failureCount += 1 + if ( !hasNotifiedDisconnected && failureCount >= DISCONNECTED_NOTICE_FAILURE_THRESHOLD && From 0f4d85a689266073eb9c73a76b1a7e01dfbf6fb8 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 10:14:10 +0100 Subject: [PATCH 2/7] Fix code-link replacement cleanup --- .../code-link-cli/src/helpers/connection.ts | 5 +- packages/code-link-shared/src/index.ts | 2 +- packages/code-link-shared/src/types.ts | 3 + plugins/code-link/src/utils/sockets.test.ts | 140 ++++++++++++++++++ plugins/code-link/src/utils/sockets.ts | 44 +++--- 5 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 plugins/code-link/src/utils/sockets.test.ts diff --git a/packages/code-link-cli/src/helpers/connection.ts b/packages/code-link-cli/src/helpers/connection.ts index d00ab9819..907f484fb 100644 --- a/packages/code-link-cli/src/helpers/connection.ts +++ b/packages/code-link-cli/src/helpers/connection.ts @@ -4,15 +4,12 @@ * Wrapper around ws.Server that normalizes handshake and surfaces callbacks. */ -import type { CliToPluginMessage, PluginToCliMessage } from "@code-link/shared" +import { CLOSE_CODE_REPLACED, type CliToPluginMessage, type PluginToCliMessage } from "@code-link/shared" import https from "node:https" import { WebSocket, WebSocketServer } from "ws" import type { CertBundle } from "./certs.ts" import { debug, error, info } from "../utils/logging.ts" -/** Custom close code sent when a new plugin tab replaces the active one. */ -export const CLOSE_CODE_REPLACED = 4001 - export interface ConnectionCallbacks { onHandshake: (client: WebSocket, message: { projectId: string; projectName: string }) => void onMessage: (message: PluginToCliMessage) => void diff --git a/packages/code-link-shared/src/index.ts b/packages/code-link-shared/src/index.ts index 22a483cb6..1c46d1400 100644 --- a/packages/code-link-shared/src/index.ts +++ b/packages/code-link-shared/src/index.ts @@ -30,4 +30,4 @@ export type { PluginToCliMessage, ProjectInfo, } from "./types.ts" -export { isCliToPluginMessage } from "./types.ts" +export { CLOSE_CODE_REPLACED, isCliToPluginMessage } from "./types.ts" diff --git a/packages/code-link-shared/src/types.ts b/packages/code-link-shared/src/types.ts index 8c5210379..e83b4345c 100644 --- a/packages/code-link-shared/src/types.ts +++ b/packages/code-link-shared/src/types.ts @@ -2,6 +2,9 @@ export type Mode = "loading" | "info" | "syncing" | "delete_confirmation" | "conflict_resolution" | "idle" | "replaced" +/** Custom close code sent when a new plugin tab replaces the active one. */ +export const CLOSE_CODE_REPLACED = 4001 + export interface ProjectInfo { id: string name: string diff --git a/plugins/code-link/src/utils/sockets.test.ts b/plugins/code-link/src/utils/sockets.test.ts new file mode 100644 index 000000000..72edd5970 --- /dev/null +++ b/plugins/code-link/src/utils/sockets.test.ts @@ -0,0 +1,140 @@ +import { CLOSE_CODE_REPLACED, type ProjectInfo } from "@code-link/shared" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { createSocketConnectionController } from "./sockets.ts" + +vi.mock("framer-plugin", () => ({ + framer: { + getProjectInfo: vi.fn(async () => ({ id: "project-id", name: "Project Name" })), + }, +})) + +class MockEventTarget { + private listeners = new Map>() + + addEventListener = vi.fn((type: string, listener: EventListener) => { + const listenersForType = this.listeners.get(type) ?? new Set() + listenersForType.add(listener) + this.listeners.set(type, listenersForType) + }) + + removeEventListener = vi.fn((type: string, listener: EventListener) => { + this.listeners.get(type)?.delete(listener) + }) + + dispatch(type: string) { + for (const listener of this.listeners.get(type) ?? []) { + listener(new Event(type)) + } + } +} + +class MockWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static instances: MockWebSocket[] = [] + + readonly url: string + readyState = MockWebSocket.CONNECTING + onopen: ((event: Event) => void) | null = null + onclose: ((event: CloseEventLike) => void) | null = null + onerror: ((event: Event) => void) | null = null + onmessage: ((event: MessageEvent) => void) | null = null + send = vi.fn() + + constructor(url: string) { + this.url = url + MockWebSocket.instances.push(this) + } + + close(code = 1000, reason = "") { + this.readyState = MockWebSocket.CLOSED + this.onclose?.({ code, reason, wasClean: true }) + } + + emitClose(code = 1000, reason = "") { + this.readyState = MockWebSocket.CLOSED + this.onclose?.({ code, reason, wasClean: true }) + } +} + +interface CloseEventLike { + code: number + reason: string + wasClean: boolean +} + +describe("createSocketConnectionController", () => { + const originalDocument = globalThis.document + const originalWindow = globalThis.window + const originalWebSocket = globalThis.WebSocket + let mockDocument: MockEventTarget & { visibilityState: DocumentVisibilityState } + let mockWindow: MockEventTarget + + beforeEach(() => { + vi.useFakeTimers() + MockWebSocket.instances = [] + + mockDocument = Object.assign(new MockEventTarget(), { + visibilityState: "visible" as DocumentVisibilityState, + }) + Object.defineProperty(mockDocument, "visibilityState", { + value: "visible", + configurable: true, + writable: true, + }) + + mockWindow = new MockEventTarget() + + globalThis.document = mockDocument as unknown as Document + globalThis.window = mockWindow as unknown as Window & typeof globalThis + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + globalThis.document = originalDocument + globalThis.window = originalWindow + globalThis.WebSocket = originalWebSocket + }) + + it("cleans up listeners and timers after a replaced close followed by stop", () => { + const setSocket = vi.fn() + const onReplaced = vi.fn() + + const controller = createSocketConnectionController({ + project: { id: "project-id", name: "Project Name" } satisfies ProjectInfo, + setSocket, + onMessage: vi.fn(async () => {}), + onConnected: vi.fn(), + onDisconnected: vi.fn(), + onReplaced, + }) + + controller.start() + + expect(mockDocument.addEventListener).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function) + ) + expect(mockWindow.addEventListener).toHaveBeenCalledWith("focus", expect.any(Function)) + expect(MockWebSocket.instances).toHaveLength(1) + + mockWindow.dispatch("focus") + expect(vi.getTimerCount()).toBe(2) + + MockWebSocket.instances[0]?.emitClose(CLOSE_CODE_REPLACED, "replaced") + controller.stop() + + expect(onReplaced).toHaveBeenCalledOnce() + expect(mockDocument.removeEventListener).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function) + ) + expect(mockWindow.removeEventListener).toHaveBeenCalledWith("focus", expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + expect(setSocket).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/plugins/code-link/src/utils/sockets.ts b/plugins/code-link/src/utils/sockets.ts index 448265850..610154503 100644 --- a/plugins/code-link/src/utils/sockets.ts +++ b/plugins/code-link/src/utils/sockets.ts @@ -1,4 +1,5 @@ import { + CLOSE_CODE_REPLACED, type CliToPluginMessage, getPortFromHash, isCliToPluginMessage, @@ -24,9 +25,6 @@ export interface SocketConnectionController { stop: () => void } -/** Custom close code sent by CLI when another plugin tab takes over. */ -const CLOSE_CODE_REPLACED = 4001 - export function createSocketConnectionController({ project, setSocket, @@ -57,6 +55,7 @@ export function createSocketConnectionController({ let hasNotifiedDisconnected = false let activeSocket: WebSocket | null = null let messageQueue: Promise = Promise.resolve() + let hasCleanedUp = false const protocol = "wss" const timers: Record | null> = { connectTrigger: null, @@ -122,6 +121,28 @@ export function createSocketConnectionController({ setSocket(socket) } + const cleanupResources = () => { + if (hasCleanedUp) return + hasCleanedUp = true + + document.removeEventListener("visibilitychange", onVisibilityChange) + window.removeEventListener("focus", onFocus) + + clearAllTimers() + const socket = activeSocket + + if (socket) { + clearSocket(socket) + } else { + setActiveSocket(null) + } + } + + const dispose = () => { + setLifecycle("disposed") + cleanupResources() + } + const clearSocket = (socket: WebSocket) => { clearTimer("connectTimeout") detachSocketHandlers(socket) @@ -327,7 +348,7 @@ export function createSocketConnectionController({ if (event.code === CLOSE_CODE_REPLACED) { log.debug("Connection replaced by another plugin tab, disposing") onReplaced() - setLifecycle("disposed") + dispose() return } @@ -405,20 +426,7 @@ export function createSocketConnectionController({ } }, stop: () => { - if (isDisposed()) return - setLifecycle("disposed") - - document.removeEventListener("visibilitychange", onVisibilityChange) - window.removeEventListener("focus", onFocus) - - clearAllTimers() - const socket = activeSocket - - if (socket) { - clearSocket(socket) - } else { - setActiveSocket(null) - } + dispose() }, } } From 75f5068989f3c53bfeb8bf5738997d232e8bf768 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 10:23:14 +0100 Subject: [PATCH 3/7] Fix lint errors in sockets.test.ts --- plugins/code-link/src/utils/sockets.test.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/plugins/code-link/src/utils/sockets.test.ts b/plugins/code-link/src/utils/sockets.test.ts index 72edd5970..0cad1a919 100644 --- a/plugins/code-link/src/utils/sockets.test.ts +++ b/plugins/code-link/src/utils/sockets.test.ts @@ -4,7 +4,7 @@ import { createSocketConnectionController } from "./sockets.ts" vi.mock("framer-plugin", () => ({ framer: { - getProjectInfo: vi.fn(async () => ({ id: "project-id", name: "Project Name" })), + getProjectInfo: vi.fn(() => Promise.resolve({ id: "project-id", name: "Project Name" })), }, })) @@ -107,7 +107,7 @@ describe("createSocketConnectionController", () => { const controller = createSocketConnectionController({ project: { id: "project-id", name: "Project Name" } satisfies ProjectInfo, setSocket, - onMessage: vi.fn(async () => {}), + onMessage: vi.fn(() => Promise.resolve()), onConnected: vi.fn(), onDisconnected: vi.fn(), onReplaced, @@ -115,10 +115,7 @@ describe("createSocketConnectionController", () => { controller.start() - expect(mockDocument.addEventListener).toHaveBeenCalledWith( - "visibilitychange", - expect.any(Function) - ) + expect(mockDocument.addEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function)) expect(mockWindow.addEventListener).toHaveBeenCalledWith("focus", expect.any(Function)) expect(MockWebSocket.instances).toHaveLength(1) @@ -129,10 +126,7 @@ describe("createSocketConnectionController", () => { controller.stop() expect(onReplaced).toHaveBeenCalledOnce() - expect(mockDocument.removeEventListener).toHaveBeenCalledWith( - "visibilitychange", - expect.any(Function) - ) + expect(mockDocument.removeEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function)) expect(mockWindow.removeEventListener).toHaveBeenCalledWith("focus", expect.any(Function)) expect(vi.getTimerCount()).toBe(0) expect(setSocket).toHaveBeenLastCalledWith(null) From 9164388f443d54a8013ab47393da2e915d35aabd Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 11:16:25 +0100 Subject: [PATCH 4/7] Close plugin with info toast when replaced by another connection --- plugins/code-link/src/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index 31fe7369a..ca82da879 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -266,6 +266,11 @@ export function App() { }) return + case "replaced": + framer.closePlugin("Replaced by another Plugin connection", { + variant: "info", + }) + default: void framer.setBackgroundMessage(backgroundStatusFromMode(state.mode)) void framer.hideUI() @@ -287,8 +292,6 @@ function backgroundStatusFromMode(mode: Mode | undefined): string | null { return null case "idle": return "Watching Files…" - case "replaced": - return "Replaced by another Plugin connection" default: return "Loading…" } From 2b68506bd23e54d0f3c039b281c24b02a0c2b924 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 11:20:46 +0100 Subject: [PATCH 5/7] Add break after closePlugin to satisfy no-fallthrough lint rule --- plugins/code-link/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index ca82da879..46be15465 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -270,7 +270,7 @@ export function App() { framer.closePlugin("Replaced by another Plugin connection", { variant: "info", }) - + // eslint-disable-next-line no-fallthrough default: void framer.setBackgroundMessage(backgroundStatusFromMode(state.mode)) void framer.hideUI() From 8f46959a8fbdab3dc30cf7d14a655ef1ef1c404c Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 14:08:06 +0100 Subject: [PATCH 6/7] Simplify cleanupResources: drop redundant setActiveSocket(null) branch --- plugins/code-link/src/utils/sockets.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/code-link/src/utils/sockets.ts b/plugins/code-link/src/utils/sockets.ts index 610154503..a939421f6 100644 --- a/plugins/code-link/src/utils/sockets.ts +++ b/plugins/code-link/src/utils/sockets.ts @@ -129,12 +129,9 @@ export function createSocketConnectionController({ window.removeEventListener("focus", onFocus) clearAllTimers() - const socket = activeSocket - if (socket) { - clearSocket(socket) - } else { - setActiveSocket(null) + if (activeSocket) { + clearSocket(activeSocket) } } From 9cfb8a6a0ed028be869007b370fde7fbc0d4093d Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 14:26:53 +0100 Subject: [PATCH 7/7] Return from closePlugin instead of falling through switch case --- plugins/code-link/src/App.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index 46be15465..980493d42 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -267,10 +267,9 @@ export function App() { return case "replaced": - framer.closePlugin("Replaced by another Plugin connection", { + return framer.closePlugin("Replaced by another Plugin connection", { variant: "info", }) - // eslint-disable-next-line no-fallthrough default: void framer.setBackgroundMessage(backgroundStatusFromMode(state.mode)) void framer.hideUI()