Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/code-link-cli/src/helpers/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 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"
Expand Down Expand Up @@ -89,7 +89,7 @@ export function initConnection(port: number, certs: CertBundle): Promise<Connect
previousActiveClient.readyState === READY_STATE.OPEN ||
previousActiveClient.readyState === READY_STATE.CONNECTING
) {
previousActiveClient.close()
previousActiveClient.close(CLOSE_CODE_REPLACED)
}
}
handlers.onHandshake?.(ws, message)
Expand Down
3 changes: 2 additions & 1 deletion packages/code-link-cli/src/helpers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { execSync } from "child_process"
import fs from "fs"
import path from "path"
import { debug } from "../utils/logging.ts"
import { debug, status } from "../utils/logging.ts"

function isInGitRepository(cwd: string): boolean {
try {
Expand Down Expand Up @@ -49,6 +49,7 @@ export function tryGitInit(projectDir: string): boolean {
return false
}

status("Initializing git repository...")
execSync("git init", { stdio: "ignore", cwd: projectDir })
didInit = true

Expand Down
2 changes: 1 addition & 1 deletion packages/code-link-cli/src/helpers/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ declare module "*.json"
private: true,
description: "Framer files synced with framer-code-link",
}
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2))
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 4))
debug("Created package.json")
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/code-link-cli/src/utils/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function findOrCreateProjectDirectory(options: {
shortProjectHash: shortId,
framerProjectName: projectName,
}
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 2))
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 4))

return { directory: projectDirectory, created: true, nameCollision }
}
Expand Down
2 changes: 1 addition & 1 deletion packages/code-link-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ export type {
PluginToCliMessage,
ProjectInfo,
} from "./types.ts"
export { isCliToPluginMessage } from "./types.ts"
export { CLOSE_CODE_REPLACED, isCliToPluginMessage } from "./types.ts"
5 changes: 4 additions & 1 deletion packages/code-link-shared/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Shared types between plugin and CLI

export type Mode = "loading" | "info" | "syncing" | "delete_confirmation" | "conflict_resolution" | "idle"
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
Expand Down
8 changes: 8 additions & 0 deletions plugins/code-link/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ export function App() {
const handleConnected = () => {
dispatch({ type: "set-mode", mode: "syncing" })
}
const handleReplaced = () => {
dispatch({ type: "set-mode", mode: "replaced" })
}

const handleMessage = createMessageHandler({ dispatch, api, syncTracker })
const controller = createSocketConnectionController({
Expand All @@ -175,6 +178,7 @@ export function App() {
onMessage: handleMessage,
onConnected: handleConnected,
onDisconnected: handleDisconnected,
onReplaced: handleReplaced,
})
controller.start()

Expand Down Expand Up @@ -262,6 +266,10 @@ export function App() {
})
return <InfoPanel command={command} />

case "replaced":
return framer.closePlugin("Replaced by another Plugin connection", {
variant: "info",
})
default:
void framer.setBackgroundMessage(backgroundStatusFromMode(state.mode))
void framer.hideUI()
Expand Down
134 changes: 134 additions & 0 deletions plugins/code-link/src/utils/sockets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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(() => Promise.resolve({ id: "project-id", name: "Project Name" })),
},
}))

class MockEventTarget {
private listeners = new Map<string, Set<EventListener>>()

addEventListener = vi.fn((type: string, listener: EventListener) => {
const listenersForType = this.listeners.get(type) ?? new Set<EventListener>()
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(() => Promise.resolve()),
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)
})
})
49 changes: 34 additions & 15 deletions plugins/code-link/src/utils/sockets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CLOSE_CODE_REPLACED,
type CliToPluginMessage,
getPortFromHash,
isCliToPluginMessage,
Expand Down Expand Up @@ -30,12 +31,14 @@ export function createSocketConnectionController({
onMessage,
onConnected,
onDisconnected,
onReplaced,
}: {
project: ProjectInfo
setSocket: (socket: WebSocket | null) => void
onMessage: (message: CliToPluginMessage, socket: WebSocket) => Promise<void>
onConnected: () => void
onDisconnected: (message: string) => void
onReplaced: () => void
}): SocketConnectionController {
const RECONNECT_BASE_MS = 500
const RECONNECT_MAX_MS = 5000
Expand All @@ -52,6 +55,7 @@ export function createSocketConnectionController({
let hasNotifiedDisconnected = false
let activeSocket: WebSocket | null = null
let messageQueue: Promise<void> = Promise.resolve()
let hasCleanedUp = false
const protocol = "wss"
const timers: Record<TimerName, ReturnType<typeof setTimeout> | null> = {
connectTrigger: null,
Expand Down Expand Up @@ -117,6 +121,25 @@ export function createSocketConnectionController({
setSocket(socket)
}

const cleanupResources = () => {
if (hasCleanedUp) return
hasCleanedUp = true

document.removeEventListener("visibilitychange", onVisibilityChange)
window.removeEventListener("focus", onFocus)

clearAllTimers()

if (activeSocket) {
clearSocket(activeSocket)
}
}

const dispose = () => {
setLifecycle("disposed")
cleanupResources()
}

const clearSocket = (socket: WebSocket) => {
clearTimer("connectTimeout")
detachSocketHandlers(socket)
Expand Down Expand Up @@ -306,7 +329,6 @@ export function createSocketConnectionController({
if (isStale()) return

setActiveSocket(null)
failureCount += 1

log.debug("WebSocket closed", {
code: event.code,
Expand All @@ -319,6 +341,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()
dispose()
return
}

failureCount += 1

if (
!hasNotifiedDisconnected &&
failureCount >= DISCONNECTED_NOTICE_FAILURE_THRESHOLD &&
Expand Down Expand Up @@ -391,20 +423,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()
},
}
}
Loading