diff --git a/.gitignore b/.gitignore index b0ca9eb65..15e93a0b5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ plugin.zip !.yarn/plugins !.yarn/sdks !.yarn/versions + + +# Code Link CLI — ignore folders (Untitled, etc.) for testing +packages/code-link-cli/*/ +!packages/code-link-cli/src/ +!packages/code-link-cli/skills/ \ No newline at end of file diff --git a/packages/code-link-cli/package.json b/packages/code-link-cli/package.json index acbe0939f..62866c5a5 100644 --- a/packages/code-link-cli/package.json +++ b/packages/code-link-cli/package.json @@ -1,6 +1,6 @@ { "name": "framer-code-link", - "version": "0.15.0", + "version": "0.17.0", "description": "CLI tool for syncing Framer code components - controller-centric architecture", "main": "dist/index.mjs", "type": "module", diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index 24e9870f1..21f0e5939 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -269,7 +269,7 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff return { state, effects } } - effects.push(log("debug", `Received file list: ${event.files.length} files`)) + effects.push(log("debug", `Received file list: ${pluralize(event.files.length, "file")}`)) // During initial file list, detect conflicts between remote snapshot and local files effects.push({ @@ -304,7 +304,11 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff // Apply safe writes if (safeWrites.length > 0) { - effects.push(log("debug", `Applying ${safeWrites.length} safe writes`), { + effects.push(log("debug", `Applying ${safeWrites.length} safe writes`)) + if (wasRecentlyDisconnected()) { + effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`)) + } + effects.push({ type: "WRITE_FILES", files: safeWrites, silent: true, @@ -313,7 +317,7 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff // Upload local-only files if (localOnly.length > 0) { - effects.push(log("debug", `Uploading ${localOnly.length} local-only files`)) + effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`)) for (const file of localOnly) { effects.push({ type: "SEND_MESSAGE", @@ -683,14 +687,18 @@ async function executeEffect( if (!config.projectDir) { const projectName = config.explicitName ?? effect.projectInfo.projectName - const directoryInfo = await findOrCreateProjectDirectory( - config.projectHash, + const directoryInfo = await findOrCreateProjectDirectory({ + projectHash: config.projectHash, projectName, - config.explicitDirectory - ) + explicitDirectory: config.explicitDirectory, + }) config.projectDir = directoryInfo.directory config.projectDirCreated = directoryInfo.created + if (directoryInfo.nameCollision) { + warn(`Folder ${projectName} already exists`) + } + // May allow customization of file directory in the future config.filesDir = `${config.projectDir}/files` debug(`Files directory: ${config.filesDir}`) @@ -702,7 +710,7 @@ async function executeEffect( case "LOAD_PERSISTED_STATE": { if (config.projectDir) { await fileMetadataCache.initialize(config.projectDir) - debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`) + debug(`Loaded persisted metadata for ${pluralize(fileMetadataCache.size(), "file")}`) } return [] } @@ -918,6 +926,7 @@ async function executeEffect( for (const fileName of confirmedFiles) { hashTracker.forget(fileName) fileMetadataCache.recordDelete(fileName) + fileDelete(fileName) } if (confirmedFiles.length > 0 && syncState.socket) { @@ -950,7 +959,7 @@ async function executeEffect( // Only show reconnect message if we actually showed the disconnect notice if (didShowDisconnect()) { success( - `Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)` + `Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)` ) status("Watching for changes...") } @@ -968,14 +977,14 @@ async function executeEffect( success(`Syncing to ${relativeDirectory} folder`) } } else if (relativeDirectory && config.projectDirCreated) { - success(`Synced into ${relativeDirectory} (${effect.updatedCount} files added)`) + success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`) } else if (relativeDirectory) { success( - `Synced into ${relativeDirectory} (${effect.updatedCount} files updated, ${effect.unchangedCount} unchanged)` + `Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)` ) } else { success( - `Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)` + `Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)` ) } // Git init after first sync so initial commit includes all synced files @@ -1073,6 +1082,15 @@ export async function start(config: Config): Promise { void (async () => { cancelDisconnectMessage() + if (syncState.mode !== "disconnected") { + if (syncState.socket === client) { + debug(`Ignoring duplicate handshake from active socket in ${syncState.mode} mode`) + return + } + debug(`New handshake received in ${syncState.mode} mode, resetting sync state`) + await processEvent({ type: "DISCONNECT" }) + } + // Only show "Connected" on initial connection, not reconnects // Reconnect confirmation happens in SYNC_COMPLETE const wasDisconnected = wasRecentlyDisconnected() @@ -1120,7 +1138,7 @@ export async function start(config: Config): Promise { break case "file-list": { - debug(`Received file list: ${message.files.length} files`) + debug(`Received file list: ${pluralize(message.files.length, "file")}`) event = { type: "REMOTE_FILE_LIST", files: message.files } break } @@ -1222,7 +1240,11 @@ export async function start(config: Config): Promise { })() }) - connection.on("disconnect", () => { + connection.on("disconnect", (client: WebSocket) => { + if (syncState.socket !== client) { + debug("[STATE] Ignoring disconnect from stale socket") + return + } // Schedule disconnect message with delay - if reconnect happens quickly, we skip it scheduleDisconnectMessage(() => { status("Disconnected, waiting to reconnect...") diff --git a/packages/code-link-cli/src/helpers/connection.ts b/packages/code-link-cli/src/helpers/connection.ts index e10720ba7..230060c0e 100644 --- a/packages/code-link-cli/src/helpers/connection.ts +++ b/packages/code-link-cli/src/helpers/connection.ts @@ -11,7 +11,7 @@ import { debug, error } from "../utils/logging.ts" export interface ConnectionCallbacks { onHandshake: (client: WebSocket, message: { projectId: string; projectName: string }) => void onMessage: (message: PluginToCliMessage) => void - onDisconnect: () => void + onDisconnect: (client: WebSocket) => void onError: (error: Error) => void } @@ -61,6 +61,7 @@ export function initConnection(port: number): Promise { wss.on("listening", () => { isReady = true debug(`WebSocket server listening on port ${port}`) + let activeClient: WebSocket | null = null wss.on("connection", (ws: WebSocket) => { const connId = ++connectionId @@ -71,13 +72,27 @@ export function initConnection(port: number): Promise { try { const message = JSON.parse(data.toString()) as PluginToCliMessage - // Special handling for handshake if (message.type === "handshake") { debug(`Received handshake (conn ${connId})`) handshakeReceived = true + const previousActiveClient = activeClient + activeClient = ws + + // Make this the active client, ignore stale close events from the old one. + if (previousActiveClient && previousActiveClient !== activeClient) { + debug(`Replacing active client with conn ${connId}`) + if ( + previousActiveClient.readyState === READY_STATE.OPEN || + previousActiveClient.readyState === READY_STATE.CONNECTING + ) { + previousActiveClient.close() + } + } handlers.onHandshake?.(ws, message) - } else if (handshakeReceived) { + } else if (handshakeReceived && activeClient === ws) { handlers.onMessage?.(message) + } else if (handshakeReceived) { + debug(`Ignoring ${message.type} from stale client (conn ${connId})`) } else { // Ignore messages before handshake - plugin will send full snapshot after debug(`Ignoring ${message.type} before handshake (conn ${connId})`) @@ -89,7 +104,12 @@ export function initConnection(port: number): Promise { ws.on("close", (code, reason) => { debug(`Client disconnected (code: ${code}, reason: ${reason.toString()})`) - handlers.onDisconnect?.() + if (activeClient === ws) { + activeClient = null + handlers.onDisconnect?.(ws) + } else { + debug(`Ignoring disconnect from stale client (conn ${connId})`) + } }) ws.on("error", err => { diff --git a/packages/code-link-cli/src/helpers/files.ts b/packages/code-link-cli/src/helpers/files.ts index 5fe2a692e..f3d82bbcd 100644 --- a/packages/code-link-cli/src/helpers/files.ts +++ b/packages/code-link-cli/src/helpers/files.ts @@ -10,7 +10,7 @@ * Controller decides WHEN to call these, but never computes conflicts itself. */ -import { fileKeyForLookup, normalizePath, sanitizeFilePath } from "@code-link/shared" +import { fileKeyForLookup, normalizePath, pluralize, sanitizeFilePath } from "@code-link/shared" import fs from "fs/promises" import path from "path" import type { Conflict, ConflictResolution, ConflictVersionData, FileInfo } from "../types.ts" @@ -96,7 +96,7 @@ export async function detectConflicts( // Persisted state keys are normalized to lowercase for case-insensitive lookup const getPersistedState = (fileName: string) => persistedState?.get(fileKeyForLookup(fileName)) - debug(`Detecting conflicts for ${String(remoteFiles.length)} remote files`) + debug(`Detecting conflicts for ${pluralize(remoteFiles.length, "remote file")}`) // Build a snapshot of all local files (keyed by lowercase for case-insensitive matching) const localFiles = await listFiles(filesDir) @@ -323,7 +323,7 @@ export async function writeRemoteFiles( hashTracker: HashTracker, installer?: { process: (fileName: string, content: string) => void } ): Promise { - debug(`Writing ${files.length} remote files`) + debug(`Writing ${pluralize(files.length, "remote file")}`) for (const file of files) { try { diff --git a/packages/code-link-cli/src/utils/project.test.ts b/packages/code-link-cli/src/utils/project.test.ts new file mode 100644 index 000000000..da14f0acb --- /dev/null +++ b/packages/code-link-cli/src/utils/project.test.ts @@ -0,0 +1,144 @@ +import { shortProjectHash } from "@code-link/shared" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { findOrCreateProjectDirectory, toDirectoryName, toPackageName } from "./project.ts" + +describe("toPackageName", () => { + it("lowercases and replaces invalid chars", () => { + expect(toPackageName("My Project")).toBe("my-project") + expect(toPackageName("Hello World!")).toBe("hello-world") + }) +}) + +describe("toDirectoryName", () => { + it("replaces invalid chars preserving case and spaces", () => { + expect(toDirectoryName("My Project")).toBe("My Project") + expect(toDirectoryName("Hello World!")).toBe("Hello World") + }) +}) + +describe("findOrCreateProjectDirectory", () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-test-")) + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("uses the project name as directory name", async () => { + const result = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "My Project", + baseDirectory: tmpDir, + }) + + expect(result.created).toBe(true) + expect(path.basename(result.directory)).toBe("My Project") + + const pkg = JSON.parse(await fs.readFile(path.join(result.directory, "package.json"), "utf-8")) + expect(pkg.shortProjectHash).toBe(shortProjectHash("hashA")) + expect(pkg.framerProjectName).toBe("My Project") + }) + + it("reuses existing directory when hash matches", async () => { + const first = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "My Project", + baseDirectory: tmpDir, + }) + const second = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "My Project", + baseDirectory: tmpDir, + }) + + expect(first.created).toBe(true) + expect(second.created).toBe(false) + expect(second.directory).toBe(first.directory) + }) + + it("creates separate directories for same-named projects with different hashes", async () => { + const projectA = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "My Project", + baseDirectory: tmpDir, + }) + const projectB = await findOrCreateProjectDirectory({ + projectHash: "hashB", + projectName: "My Project", + baseDirectory: tmpDir, + }) + + // First gets the bare name, second gets name-hash + expect(path.basename(projectA.directory)).toBe("My Project") + expect(path.basename(projectB.directory)).toBe(`My Project-${shortProjectHash("hashB")}`) + + // Must be distinct directories + expect(projectA.directory).not.toBe(projectB.directory) + expect(projectA.created).toBe(true) + expect(projectB.created).toBe(true) + + // Each has correct hash in package.json + const pkgA = JSON.parse(await fs.readFile(path.join(projectA.directory, "package.json"), "utf-8")) + const pkgB = JSON.parse(await fs.readFile(path.join(projectB.directory, "package.json"), "utf-8")) + expect(pkgA.shortProjectHash).toBe(shortProjectHash("hashA")) + expect(pkgB.shortProjectHash).toBe(shortProjectHash("hashB")) + }) + + it("does not overwrite first project's package.json when second has same name", async () => { + const projectA = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "My Project", + baseDirectory: tmpDir, + }) + + // Write a file into project A to simulate synced state + await fs.writeFile(path.join(projectA.directory, "files", "Component.tsx"), "export default () =>
") + + const projectB = await findOrCreateProjectDirectory({ + projectHash: "hashB", + projectName: "My Project", + baseDirectory: tmpDir, + }) + + // Project A's package.json must still have its own hash + const pkgA = JSON.parse(await fs.readFile(path.join(projectA.directory, "package.json"), "utf-8")) + expect(pkgA.shortProjectHash).toBe(shortProjectHash("hashA")) + + // Project A's files must still exist + const content = await fs.readFile(path.join(projectA.directory, "files", "Component.tsx"), "utf-8") + expect(content).toBe("export default () =>
") + + // Project B's directory is separate and empty + const filesB = await fs.readdir(path.join(projectB.directory, "files")) + expect(filesB).toHaveLength(0) + }) + + it("uses explicit directory when provided", async () => { + const explicitDir = path.join(tmpDir, "custom-dir") + const result = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "My Project", + explicitDirectory: explicitDir, + }) + + expect(result.directory).toBe(explicitDir) + expect(result.created).toBe(false) + }) + + it("falls back to project-[hash] when project name is empty after sanitization", async () => { + const result = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "!!!", + baseDirectory: tmpDir, + }) + const shortId = shortProjectHash("hashA") + + expect(path.basename(result.directory)).toBe(`project-${shortId}`) + }) +}) diff --git a/packages/code-link-cli/src/utils/project.ts b/packages/code-link-cli/src/utils/project.ts index 374c7f2b2..42b103372 100644 --- a/packages/code-link-cli/src/utils/project.ts +++ b/packages/code-link-cli/src/utils/project.ts @@ -36,18 +36,21 @@ export async function getProjectHashFromCwd(): Promise { } } -export async function findOrCreateProjectDirectory( - projectHash: string, - projectName?: string, +export async function findOrCreateProjectDirectory(options: { + projectHash: string + projectName?: string explicitDirectory?: string -): Promise<{ directory: string; created: boolean }> { + baseDirectory?: string +}): Promise<{ directory: string; created: boolean; nameCollision?: boolean }> { + const { projectHash, projectName, explicitDirectory, baseDirectory } = options + if (explicitDirectory) { const resolved = path.resolve(explicitDirectory) await fs.mkdir(path.join(resolved, "files"), { recursive: true }) return { directory: resolved, created: false } } - const cwd = process.cwd() + const cwd = baseDirectory ?? process.cwd() const existing = await findExistingProjectDirectory(cwd, projectHash) if (existing) { return { directory: existing, created: false } @@ -60,7 +63,8 @@ export async function findOrCreateProjectDirectory( const directoryName = toDirectoryName(projectName) const pkgName = toPackageName(projectName) const shortId = shortProjectHash(projectHash) - const projectDirectory = path.join(cwd, directoryName || shortId) + const baseName = directoryName || `project-${shortId}` + const { directory: projectDirectory, nameCollision } = await findAvailableDirectory(cwd, baseName, shortId) await fs.mkdir(path.join(projectDirectory, "files"), { recursive: true }) const pkg: PackageJson = { @@ -72,7 +76,28 @@ export async function findOrCreateProjectDirectory( } await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 2)) - return { directory: projectDirectory, created: true } + return { directory: projectDirectory, created: true, nameCollision } +} + +/** + * Returns a directory path that doesn't collide with an existing project. + * Tries the bare name first, falls back to name-{shortId} if taken. + */ +async function findAvailableDirectory( + baseDir: string, + name: string, + shortId: string +): Promise<{ directory: string; nameCollision: boolean }> { + const candidate = path.join(baseDir, name) + try { + await fs.access(candidate) + // Directory exists — it belongs to a different project (findExistingProjectDirectory + // already checked for a hash match). Disambiguate with the project hash. + return { directory: path.join(baseDir, `${name}-${shortId}`), nameCollision: true } + } catch { + // Doesn't exist yet, use the bare name + return { directory: candidate, nameCollision: false } + } } async function findExistingProjectDirectory(baseDirectory: string, projectHash: string): Promise { diff --git a/packages/code-link-cli/src/utils/state-persistence.ts b/packages/code-link-cli/src/utils/state-persistence.ts index 0ee945e7b..f90f42f6f 100644 --- a/packages/code-link-cli/src/utils/state-persistence.ts +++ b/packages/code-link-cli/src/utils/state-persistence.ts @@ -6,6 +6,7 @@ * (hash matches), because that means the file wasn't edited while CLI was offline. */ +import { pluralize } from "@code-link/shared" import { createHash } from "crypto" import fs from "fs/promises" import path from "path" @@ -68,7 +69,7 @@ export async function loadPersistedState(projectDir: string): Promise(null) const api = useConstant(() => new CodeFilesAPI()) const syncTracker = useConstant(createSyncTracker) - const retryTimeoutRef = useRef | null>(null) - const connectTimeoutRef = useRef | null>(null) - const connectionAttemptsRef = useRef(0) - const failureCountRef = useRef(0) const command = state.project && `npx framer-code-link ${shortProjectHash(state.project.id)}` @@ -159,155 +155,30 @@ export function App() { return } - // Reset debug counters when project/permissions change - connectionAttemptsRef.current = 0 - failureCountRef.current = 0 - - const handleMessage = createMessageHandler({ dispatch, api, syncTracker }) - - let disposed = false - - const clearRetry = () => { - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current) - retryTimeoutRef.current = null - } + const setSocket = (socket: WebSocket | null) => { + socketRef.current = socket } - const clearConnectTimeout = () => { - if (connectTimeoutRef.current) { - clearTimeout(connectTimeoutRef.current) - connectTimeoutRef.current = null - } + const handleDisconnected = (message: string) => { + dispatch({ type: "socket-disconnected", message }) } - - const scheduleReconnect = () => { - if (disposed) return - clearRetry() - retryTimeoutRef.current = setTimeout(() => { - connect() - }, 2000) + const handleConnected = () => { + dispatch({ type: "set-mode", mode: "syncing" }) } - const connect = () => { - if (disposed) return - if ( - socketRef.current?.readyState === WebSocket.OPEN || - socketRef.current?.readyState === WebSocket.CONNECTING - ) { - log.debug("WebSocket already active – skipping connect") - return - } - - if (!state.project) { - log.debug("Error loading Project Info") - return - } - - const port = getPortFromHash(state.project.id) - const attempt = ++connectionAttemptsRef.current - const projectName = state.project.name - const projectShortHash = shortProjectHash(state.project.id) - - log.debug("Opening WebSocket", { port, attempt, project: projectName }) - const socket = new WebSocket(`ws://localhost:${port}`) - socketRef.current = socket - // Timeout to prevent hanging on the socket connection which would sometimes make loading slow - const connectTimeoutMs = 1200 - clearConnectTimeout() - connectTimeoutRef.current = setTimeout(() => { - if (socket.readyState === WebSocket.CONNECTING) { - log.debug("WebSocket connect timeout – closing stale socket", { - port, - attempt, - timeoutMs: connectTimeoutMs, - }) - socket.close() - } - }, connectTimeoutMs) - - const isStale = () => socketRef.current !== socket - - socket.onopen = async () => { - clearConnectTimeout() - if (disposed || isStale()) { - socket.close() - return - } - failureCountRef.current = 0 - clearRetry() - log.debug("WebSocket connected, sending handshake") - // Don't change mode here - wait for CLI to confirm via request-files - // This prevents UI flashing during failed handshakes - const latestProjectInfo = await framer.getProjectInfo() - log.debug("Project info:", latestProjectInfo) - socket.send( - JSON.stringify({ - type: "handshake", - projectId: latestProjectInfo.id, - projectName: latestProjectInfo.name, - }) - ) - } - - socket.onmessage = event => { - if (isStale()) return - const parsed: unknown = JSON.parse(event.data as string) - if (!isCliToPluginMessage(parsed)) { - log.warn("Invalid message received:", parsed) - return - } - log.debug("Received message:", parsed.type) - void handleMessage(parsed, socket) - } - - socket.onclose = event => { - clearConnectTimeout() - if (disposed || isStale()) return - socketRef.current = null - const failureCount = ++failureCountRef.current - log.debug("WebSocket closed – scheduling reconnect", { - code: event.code, - reason: event.reason || "none", - wasClean: event.wasClean, - port, - attempt, - project: projectName, - failureCount, - }) - dispatch({ - type: "socket-disconnected", - message: `Cannot reach CLI for ${projectName} on port ${port}. Run: npx framer-code-link ${projectShortHash}`, - }) - scheduleReconnect() - } - - socket.onerror = event => { - clearConnectTimeout() - if (isStale()) return - const failureCount = failureCountRef.current - log.debug("WebSocket error event", { - type: event.type, - port, - attempt, - project: projectName, - failureCount, - }) - } - } - - connect() + const handleMessage = createMessageHandler({ dispatch, api, syncTracker }) + const controller = createSocketConnectionController({ + project: state.project, + setSocket, + onMessage: handleMessage, + onConnected: handleConnected, + onDisconnected: handleDisconnected, + }) + controller.start() return () => { - disposed = true log.debug("Cleaning up socket connection") - clearRetry() - clearConnectTimeout() - const socket = socketRef.current - socketRef.current = null - if (socket && socket.readyState === WebSocket.OPEN) { - socket.close() - } + controller.stop() } }, [state.project, state.permissionsGranted, api, syncTracker]) diff --git a/plugins/code-link/src/main.tsx b/plugins/code-link/src/main.tsx index e3db46486..df9ca3cc5 100644 --- a/plugins/code-link/src/main.tsx +++ b/plugins/code-link/src/main.tsx @@ -6,10 +6,12 @@ import ReactDOM from "react-dom/client" import { App } from "./App.tsx" import { LogLevel, setLogLevel } from "./utils/logger" -// Enable debug logging in development -if (import.meta.env.DEV) { - setLogLevel(LogLevel.DEBUG) -} +// Auto-enable debug logs while running in Vite dev. +const logLevel = import.meta.env.DEV ? LogLevel.DEBUG : LogLevel.INFO +setLogLevel(logLevel) +console.info( + `[INFO] Log level ${LogLevel[logLevel]} ${import.meta.env.DEV ? "because in dev mode" : "because default mode"}` +) const root = document.getElementById("root") if (!root) throw new Error("Root element not found") diff --git a/plugins/code-link/src/utils/sockets.ts b/plugins/code-link/src/utils/sockets.ts new file mode 100644 index 000000000..6f439486d --- /dev/null +++ b/plugins/code-link/src/utils/sockets.ts @@ -0,0 +1,408 @@ +import { + type CliToPluginMessage, + getPortFromHash, + isCliToPluginMessage, + type ProjectInfo, + shortProjectHash, +} from "@code-link/shared" +import { framer } from "framer-plugin" +import * as log from "./logger" + +/** + * Socket lifecycle controller for Code Link. + * + * - Keeps one active connection to the project-specific CLI port. + * - Retries while visible; pauses when hidden after grace period. + * - Resumes on focus/visibility changes in the Plugin window. + */ +type LifecycleState = "created" | "active" | "paused" | "disposed" +type ResumeSource = "focus" | "visibilitychange" +type TimerName = "connectTrigger" | "connectTimeout" | "hiddenGrace" + +export interface SocketConnectionController { + start: () => void + stop: () => void +} + +export function createSocketConnectionController({ + project, + setSocket, + onMessage, + onConnected, + onDisconnected, +}: { + project: ProjectInfo + setSocket: (socket: WebSocket | null) => void + onMessage: (message: CliToPluginMessage, socket: WebSocket) => Promise + onConnected: () => void + onDisconnected: (message: string) => void +}): SocketConnectionController { + const RECONNECT_BASE_MS = 500 + const RECONNECT_MAX_MS = 5000 + const VISIBLE_RECONNECT_MAX_MS = 1200 + const CONNECT_TIMEOUT_MS = 1500 + const HIDDEN_GRACE_MS = 5_000 + const WAKE_DEBOUNCE_MS = 300 + const DISCONNECTED_NOTICE_FAILURE_THRESHOLD = 2 + + let lifecycle: LifecycleState = "created" + let connectionAttempt = 0 + let failureCount = 0 + let socketToken = 0 + let hasNotifiedDisconnected = false + let activeSocket: WebSocket | null = null + let messageQueue: Promise = Promise.resolve() + const timers: Record | null> = { + connectTrigger: null, + connectTimeout: null, + hiddenGrace: null, + } + + const projectName = project.name + const projectShortHash = shortProjectHash(project.id) + const port = getPortFromHash(project.id) + + const toDisconnectedMessage = () => { + return `Cannot reach CLI for ${projectName} on port ${port}. Run: npx framer-code-link ${projectShortHash}` + } + + const setLifecycle = (next: LifecycleState) => { + if (lifecycle === next) return + lifecycle = next + } + + const isDisposed = () => lifecycle === "disposed" + + const clearTimer = (name: TimerName) => { + const timer = timers[name] + if (timer === null) return + clearTimeout(timer) + timers[name] = null + } + + const setTimer = (name: TimerName, delay: number, callback: () => void) => { + clearTimer(name) + timers[name] = setTimeout(() => { + timers[name] = null + callback() + }, delay) + } + + const clearAllTimers = () => { + clearTimer("connectTrigger") + clearTimer("connectTimeout") + clearTimer("hiddenGrace") + } + + const socketIsActive = (socket: WebSocket | null) => { + return socket?.readyState === WebSocket.CONNECTING || socket?.readyState === WebSocket.OPEN + } + + const closeSocket = (socket: WebSocket) => { + if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) { + socket.close() + } + } + + const detachSocketHandlers = (socket: WebSocket) => { + socket.onopen = null + socket.onclose = null + socket.onerror = null + socket.onmessage = null + } + + const setActiveSocket = (socket: WebSocket | null) => { + activeSocket = socket + setSocket(socket) + } + + const clearSocket = (socket: WebSocket) => { + clearTimer("connectTimeout") + detachSocketHandlers(socket) + closeSocket(socket) + if (activeSocket === socket) { + setActiveSocket(null) + } + } + + const computeBackoffDelay = () => { + const exponent = Math.min(failureCount, 4) + const base = Math.min(RECONNECT_BASE_MS * 2 ** exponent, RECONNECT_MAX_MS) + const jitter = Math.floor(base * 0.2 * Math.random()) + return base - jitter + } + + const computeVisibleReconnectDelay = () => { + // First 2 failures: fast retry at half base delay; then normal backoff capped for visible + if (failureCount <= 2) { + return Math.floor(RECONNECT_BASE_MS * 0.5) + } + return Math.min(computeBackoffDelay(), VISIBLE_RECONNECT_MAX_MS) + } + + const getConnectTimeoutMs = () => { + // After 8+ failures, give CLI more time to start; otherwise use base timeout + if (failureCount >= 8) { + return CONNECT_TIMEOUT_MS * 2 + } + return CONNECT_TIMEOUT_MS + } + + const resetReconnectBackoff = (reason: string) => { + if (failureCount === 0) return + log.debug("[connection] resetting reconnect backoff", { + reason, + previousFailureCount: failureCount, + }) + failureCount = 0 + } + + const startHiddenGracePeriod = () => { + setTimer("hiddenGrace", HIDDEN_GRACE_MS, () => { + if (isDisposed()) return + if (document.visibilityState === "visible") return + if (socketIsActive(activeSocket)) return + setLifecycle("paused") + clearTimer("connectTrigger") + }) + } + + const scheduleConnect = (reason: string, delay: number) => { + if (isDisposed() || lifecycle === "paused") return + setLifecycle("active") + setTimer("connectTrigger", delay, () => { + connect(reason, true) + }) + } + + const scheduleReconnect = (reason: string, delay = computeBackoffDelay()) => { + scheduleConnect(reason, delay) + } + + const enqueueMessage = (message: CliToPluginMessage, socket: WebSocket) => { + messageQueue = messageQueue + .catch(() => { + // Keep queue alive after prior handler failures. + }) + .then(async () => { + if (isDisposed()) return + await onMessage(message, socket) + }) + .catch((error: unknown) => { + log.error("Unhandled error while processing WebSocket message:", error) + }) + } + + const connect = (source: string, isRetry = false) => { + if (isDisposed()) return + if (lifecycle === "paused" && isRetry) return + if (activeSocket?.readyState === WebSocket.OPEN) { + return + } + if (activeSocket?.readyState === WebSocket.CONNECTING) { + return + } + if (activeSocket && activeSocket.readyState !== WebSocket.CLOSED) { + clearSocket(activeSocket) + } + + const attempt = ++connectionAttempt + setLifecycle("active") + log.debug("[connection] opening socket", { + source, + isRetry, + attempt, + port, + failureCount, + visibilityState: document.visibilityState, + }) + + const socket = new WebSocket(`ws://localhost:${port}`) + const token = ++socketToken + setActiveSocket(socket) + const connectTimeoutMs = getConnectTimeoutMs() + + setTimer("connectTimeout", connectTimeoutMs, () => { + if (isDisposed() || token !== socketToken) return + if (socket.readyState === WebSocket.CONNECTING) { + log.debug("WebSocket connect timeout - closing stale socket", { + port, + attempt, + timeoutMs: connectTimeoutMs, + }) + closeSocket(socket) + } + }) + + const isStale = () => isDisposed() || token !== socketToken || activeSocket !== socket + + socket.onopen = async () => { + clearTimer("connectTimeout") + if (isStale()) { + closeSocket(socket) + return + } + + onConnected() + failureCount = 0 + hasNotifiedDisconnected = false + clearTimer("connectTrigger") + clearTimer("hiddenGrace") + setLifecycle("active") + log.debug("WebSocket connected, sending handshake", { port, attempt, project: projectName }) + + try { + const latestProjectInfo = await framer.getProjectInfo() + if (isStale() || socket.readyState !== WebSocket.OPEN) return + socket.send( + JSON.stringify({ + type: "handshake", + projectId: latestProjectInfo.id, + projectName: latestProjectInfo.name, + }) + ) + } catch (error) { + log.warn("Failed to fetch project info for handshake:", error) + } + } + + socket.onmessage = event => { + if (isStale()) return + if (typeof event.data !== "string") { + log.warn("Received non-text WebSocket payload, ignoring", { + payloadType: typeof event.data, + }) + return + } + let parsed: unknown + try { + parsed = JSON.parse(event.data) + } catch (error) { + log.warn("Failed to parse WebSocket payload:", error) + return + } + if (!isCliToPluginMessage(parsed)) { + log.warn("Invalid message received:", parsed) + return + } + enqueueMessage(parsed, socket) + } + + socket.onerror = event => { + if (isStale()) return + log.debug("[connection] WebSocket error event (expected while reconnecting)", { + type: event.type, + port, + attempt, + project: projectName, + failureCount, + visibilityState: document.visibilityState, + }) + } + + socket.onclose = event => { + clearTimer("connectTimeout") + if (isStale()) return + + setActiveSocket(null) + failureCount += 1 + + log.debug("WebSocket closed", { + code: event.code, + reason: event.reason || "none", + wasClean: event.wasClean, + port, + attempt, + project: projectName, + failureCount, + }) + + if ( + !hasNotifiedDisconnected && + failureCount >= DISCONNECTED_NOTICE_FAILURE_THRESHOLD && + document.visibilityState === "visible" + ) { + hasNotifiedDisconnected = true + onDisconnected(toDisconnectedMessage()) + } + + if (isDisposed()) return + if (document.visibilityState === "visible") { + scheduleReconnect("socket-close-visible", computeVisibleReconnectDelay()) + return + } + + startHiddenGracePeriod() + scheduleReconnect("socket-close-hidden", RECONNECT_BASE_MS) + } + } + + const hardResume = (source: ResumeSource) => { + if (isDisposed()) return + clearTimer("hiddenGrace") + clearTimer("connectTrigger") + setLifecycle("active") + + const socket = activeSocket + if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) { + return + } + resetReconnectBackoff(`resume:${source}`) + if (socket) { + clearSocket(socket) + } + + connect(`resume:${source}`) + } + + const queueHardResume = (source: ResumeSource) => { + setTimer("connectTrigger", WAKE_DEBOUNCE_MS, () => { + hardResume(source) + }) + } + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + queueHardResume("visibilitychange") + return + } + clearTimer("connectTrigger") + startHiddenGracePeriod() + } + + const onFocus = () => { + queueHardResume("focus") + } + + return { + start: () => { + if (lifecycle !== "created") return + setLifecycle("active") + + document.addEventListener("visibilitychange", onVisibilityChange) + window.addEventListener("focus", onFocus) + + if (document.visibilityState === "visible") { + connect("start") + } else { + startHiddenGracePeriod() + } + }, + 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) + } + }, + } +}