From f2265ace9e727ebbaa0a4a913254cd8695faa359 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Fri, 13 Feb 2026 12:12:13 +0100 Subject: [PATCH 01/14] Allow for multiple directories with the same name --- .../code-link-cli/src/utils/project.test.ts | 112 ++++++++++++++++++ packages/code-link-cli/src/utils/project.ts | 10 +- 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 packages/code-link-cli/src/utils/project.test.ts 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..9c8a84bd1 --- /dev/null +++ b/packages/code-link-cli/src/utils/project.test.ts @@ -0,0 +1,112 @@ +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("creates directory with hash suffix in name", async () => { + const result = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) + const shortId = shortProjectHash("hashA") + + expect(result.created).toBe(true) + expect(path.basename(result.directory)).toBe(`My Project-${shortId}`) + + const pkg = JSON.parse(await fs.readFile(path.join(result.directory, "package.json"), "utf-8")) + expect(pkg.shortProjectHash).toBe(shortId) + expect(pkg.framerProjectName).toBe("My Project") + }) + + it("reuses existing directory when hash matches", async () => { + const first = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) + const second = await findOrCreateProjectDirectory("hashA", "My Project", undefined, 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("hashA", "My Project", undefined, tmpDir) + const projectB = await findOrCreateProjectDirectory("hashB", "My Project", undefined, tmpDir) + + // 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")) + + // Files in project A are untouched after creating project B + const filesA = await fs.readdir(path.join(projectA.directory, "files")) + expect(filesA).toHaveLength(0) // empty but the dir still exists and is separate + }) + + it("does not overwrite first project's package.json when second has same name", async () => { + // This is the exact bug scenario: two projects named "My Project" + // Before the fix, the second would overwrite the first's package.json + const projectA = await findOrCreateProjectDirectory("hashA", "My Project", undefined, 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("hashB", "My Project", undefined, 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("hashA", "My Project", 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 () => { + // A name like "!!!" sanitizes to "" via toDirectoryName + const result = await findOrCreateProjectDirectory("hashA", "!!!", undefined, 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..6c6195140 100644 --- a/packages/code-link-cli/src/utils/project.ts +++ b/packages/code-link-cli/src/utils/project.ts @@ -39,7 +39,8 @@ export async function getProjectHashFromCwd(): Promise { export async function findOrCreateProjectDirectory( projectHash: string, projectName?: string, - explicitDirectory?: string + explicitDirectory?: string, + baseDirectory?: string ): Promise<{ directory: string; created: boolean }> { if (explicitDirectory) { const resolved = path.resolve(explicitDirectory) @@ -47,7 +48,7 @@ export async function findOrCreateProjectDirectory( 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 +61,10 @@ export async function findOrCreateProjectDirectory( const directoryName = toDirectoryName(projectName) const pkgName = toPackageName(projectName) const shortId = shortProjectHash(projectHash) - const projectDirectory = path.join(cwd, directoryName || shortId) + // Include short project hash in directory name to avoid collisions + // when multiple projects share the same name + const dirSuffix = directoryName ? `${directoryName}-${shortId}` : `project-${shortId}` + const projectDirectory = path.join(cwd, dirSuffix) await fs.mkdir(path.join(projectDirectory, "files"), { recursive: true }) const pkg: PackageJson = { From d60563fb3bf22966d55e4919a60ff06b7badf04a Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Fri, 13 Feb 2026 12:46:05 +0100 Subject: [PATCH 02/14] Drive by pluralise fix --- packages/code-link-cli/src/utils/state-persistence.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 Date: Fri, 13 Feb 2026 12:46:21 +0100 Subject: [PATCH 03/14] Improved multi-project handling --- packages/code-link-cli/src/controller.ts | 20 +++++++----- packages/code-link-cli/src/helpers/files.ts | 6 ++-- .../code-link-cli/src/utils/project.test.ts | 18 +++++------ packages/code-link-cli/src/utils/project.ts | 31 +++++++++++++++---- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index 24e9870f1..608263337 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({ @@ -313,7 +313,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", @@ -691,6 +691,10 @@ async function executeEffect( 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 +706,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 [] } @@ -950,7 +954,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 +972,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 @@ -1120,7 +1124,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 } 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 index 9c8a84bd1..caebd2a9c 100644 --- a/packages/code-link-cli/src/utils/project.test.ts +++ b/packages/code-link-cli/src/utils/project.test.ts @@ -30,15 +30,14 @@ describe("findOrCreateProjectDirectory", () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - it("creates directory with hash suffix in name", async () => { + it("uses the project name as directory name", async () => { const result = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) - const shortId = shortProjectHash("hashA") expect(result.created).toBe(true) - expect(path.basename(result.directory)).toBe(`My Project-${shortId}`) + 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(shortId) + expect(pkg.shortProjectHash).toBe(shortProjectHash("hashA")) expect(pkg.framerProjectName).toBe("My Project") }) @@ -55,6 +54,10 @@ describe("findOrCreateProjectDirectory", () => { const projectA = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) const projectB = await findOrCreateProjectDirectory("hashB", "My Project", undefined, 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) @@ -65,15 +68,9 @@ describe("findOrCreateProjectDirectory", () => { 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")) - - // Files in project A are untouched after creating project B - const filesA = await fs.readdir(path.join(projectA.directory, "files")) - expect(filesA).toHaveLength(0) // empty but the dir still exists and is separate }) it("does not overwrite first project's package.json when second has same name", async () => { - // This is the exact bug scenario: two projects named "My Project" - // Before the fix, the second would overwrite the first's package.json const projectA = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) // Write a file into project A to simulate synced state @@ -103,7 +100,6 @@ describe("findOrCreateProjectDirectory", () => { }) it("falls back to project-[hash] when project name is empty after sanitization", async () => { - // A name like "!!!" sanitizes to "" via toDirectoryName const result = await findOrCreateProjectDirectory("hashA", "!!!", undefined, tmpDir) const shortId = shortProjectHash("hashA") diff --git a/packages/code-link-cli/src/utils/project.ts b/packages/code-link-cli/src/utils/project.ts index 6c6195140..b9082c368 100644 --- a/packages/code-link-cli/src/utils/project.ts +++ b/packages/code-link-cli/src/utils/project.ts @@ -41,7 +41,7 @@ export async function findOrCreateProjectDirectory( projectName?: string, explicitDirectory?: string, baseDirectory?: string -): Promise<{ directory: string; created: boolean }> { +): Promise<{ directory: string; created: boolean; nameCollision?: boolean }> { if (explicitDirectory) { const resolved = path.resolve(explicitDirectory) await fs.mkdir(path.join(resolved, "files"), { recursive: true }) @@ -61,10 +61,8 @@ export async function findOrCreateProjectDirectory( const directoryName = toDirectoryName(projectName) const pkgName = toPackageName(projectName) const shortId = shortProjectHash(projectHash) - // Include short project hash in directory name to avoid collisions - // when multiple projects share the same name - const dirSuffix = directoryName ? `${directoryName}-${shortId}` : `project-${shortId}` - const projectDirectory = path.join(cwd, dirSuffix) + 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 = { @@ -76,7 +74,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 { From 13897d7ccb888725c2447e1e8f64b2bbda22ed59 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Fri, 13 Feb 2026 13:38:02 +0100 Subject: [PATCH 04/14] Better reconnection strategy --- plugins/code-link/src/App.tsx | 49 +++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index 57c9fe9e8..d50135e0d 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -181,12 +181,26 @@ export function App() { } } + // Reconnect strategy: poll every 1s while the plugin is visible or for 20s + // after going hidden. Pause when hidden longer; resume on visibility/focus. + let paused = false + let gracePeriodTimer: ReturnType | null = null + const RECONNECT_INTERVAL_MS = 1000 + const GRACE_PERIOD_MS = 20_000 + + const clearGracePeriod = () => { + if (gracePeriodTimer) { + clearTimeout(gracePeriodTimer) + gracePeriodTimer = null + } + } + const scheduleReconnect = () => { - if (disposed) return + if (disposed || paused) return clearRetry() retryTimeoutRef.current = setTimeout(() => { connect() - }, 2000) + }, RECONNECT_INTERVAL_MS) } const connect = () => { @@ -296,6 +310,34 @@ export function App() { } } + const resumePolling = () => { + clearGracePeriod() + paused = false + log.debug("Resuming reconnect polling") + connect() + } + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + resumePolling() + } else { + // Going hidden — keep polling for a grace period, then pause + clearGracePeriod() + gracePeriodTimer = setTimeout(() => { + log.debug("Grace period expired, pausing reconnect polling") + paused = true + clearRetry() + }, GRACE_PERIOD_MS) + } + } + + const onFocus = () => { + resumePolling() + } + + document.addEventListener("visibilitychange", onVisibilityChange) + window.addEventListener("focus", onFocus) + connect() return () => { @@ -303,6 +345,9 @@ export function App() { log.debug("Cleaning up socket connection") clearRetry() clearConnectTimeout() + clearGracePeriod() + document.removeEventListener("visibilitychange", onVisibilityChange) + window.removeEventListener("focus", onFocus) const socket = socketRef.current socketRef.current = null if (socket && socket.readyState === WebSocket.OPEN) { From 0cdc32f4db871acfbbc264b30c65614c1701c844 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Fri, 13 Feb 2026 17:47:30 +0100 Subject: [PATCH 05/14] Update function signature --- packages/code-link-cli/src/controller.ts | 8 +-- .../code-link-cli/src/utils/project.test.ts | 54 +++++++++++++++---- packages/code-link-cli/src/utils/project.ts | 12 +++-- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index 608263337..4f6fbb951 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -683,11 +683,11 @@ 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 diff --git a/packages/code-link-cli/src/utils/project.test.ts b/packages/code-link-cli/src/utils/project.test.ts index caebd2a9c..da14f0acb 100644 --- a/packages/code-link-cli/src/utils/project.test.ts +++ b/packages/code-link-cli/src/utils/project.test.ts @@ -31,7 +31,11 @@ describe("findOrCreateProjectDirectory", () => { }) it("uses the project name as directory name", async () => { - const result = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) + const result = await findOrCreateProjectDirectory({ + projectHash: "hashA", + projectName: "My Project", + baseDirectory: tmpDir, + }) expect(result.created).toBe(true) expect(path.basename(result.directory)).toBe("My Project") @@ -42,8 +46,16 @@ describe("findOrCreateProjectDirectory", () => { }) it("reuses existing directory when hash matches", async () => { - const first = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) - const second = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) + 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) @@ -51,8 +63,16 @@ describe("findOrCreateProjectDirectory", () => { }) it("creates separate directories for same-named projects with different hashes", async () => { - const projectA = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) - const projectB = await findOrCreateProjectDirectory("hashB", "My Project", undefined, tmpDir) + 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") @@ -71,12 +91,20 @@ describe("findOrCreateProjectDirectory", () => { }) it("does not overwrite first project's package.json when second has same name", async () => { - const projectA = await findOrCreateProjectDirectory("hashA", "My Project", undefined, tmpDir) + 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("hashB", "My Project", undefined, tmpDir) + 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")) @@ -93,14 +121,22 @@ describe("findOrCreateProjectDirectory", () => { it("uses explicit directory when provided", async () => { const explicitDir = path.join(tmpDir, "custom-dir") - const result = await findOrCreateProjectDirectory("hashA", "My Project", explicitDir) + 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("hashA", "!!!", undefined, tmpDir) + 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 b9082c368..42b103372 100644 --- a/packages/code-link-cli/src/utils/project.ts +++ b/packages/code-link-cli/src/utils/project.ts @@ -36,12 +36,14 @@ export async function getProjectHashFromCwd(): Promise { } } -export async function findOrCreateProjectDirectory( - projectHash: string, - projectName?: string, - explicitDirectory?: string, +export async function findOrCreateProjectDirectory(options: { + projectHash: string + projectName?: string + explicitDirectory?: string baseDirectory?: string -): Promise<{ directory: string; created: boolean; nameCollision?: boolean }> { +}): 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 }) From 2fd4d358bfe8e0f1edf80912c23e110e844c2c89 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Wed, 18 Feb 2026 16:21:44 +0100 Subject: [PATCH 06/14] Rewrite connection logic --- packages/code-link-cli/src/controller.ts | 15 +- .../code-link-cli/src/helpers/connection.ts | 25 +- plugins/code-link/src/App.tsx | 215 +---------- plugins/code-link/src/utils/sockets.ts | 339 ++++++++++++++++++ 4 files changed, 393 insertions(+), 201 deletions(-) create mode 100644 plugins/code-link/src/utils/sockets.ts diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index 4f6fbb951..ca025edd6 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -1077,6 +1077,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() @@ -1226,7 +1235,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..786d0a3da 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 @@ -75,9 +76,22 @@ export function initConnection(port: number): Promise { if (message.type === "handshake") { debug(`Received handshake (conn ${connId})`) handshakeReceived = true + const previousActiveClient = activeClient + activeClient = ws + if (previousActiveClient && previousActiveClient !== ws) { + 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 +103,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/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index d50135e0d..01cb7ac90 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -2,8 +2,6 @@ import { type CliToPluginMessage, type ConflictSummary, createSyncTracker, - getPortFromHash, - isCliToPluginMessage, type Mode, type PendingDelete, type ProjectInfo, @@ -16,6 +14,7 @@ import { CodeFilesAPI } from "./api" import { copyToClipboard } from "./utils/clipboard" import { computeLineDiff } from "./utils/diffing" import * as log from "./utils/logger" +import { createSocketConnectionController } from "./utils/sockets" import { useConstant } from "./utils/useConstant" interface State { @@ -90,10 +89,6 @@ export function App() { const socketRef = useRef(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)}` @@ -149,6 +144,14 @@ export function App() { } }, [state.project, state.permissionsGranted, api, syncTracker]) + const setSocket = useCallback((socket: WebSocket | null) => { + socketRef.current = socket + }, []) + + const handleDisconnected = useCallback((message: string) => { + dispatch({ type: "socket-disconnected", message }) + }, []) + // Socket connection useEffect(() => { if (!state.project || !state.permissionsGranted) { @@ -159,202 +162,20 @@ 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 clearConnectTimeout = () => { - if (connectTimeoutRef.current) { - clearTimeout(connectTimeoutRef.current) - connectTimeoutRef.current = null - } - } - - // Reconnect strategy: poll every 1s while the plugin is visible or for 20s - // after going hidden. Pause when hidden longer; resume on visibility/focus. - let paused = false - let gracePeriodTimer: ReturnType | null = null - const RECONNECT_INTERVAL_MS = 1000 - const GRACE_PERIOD_MS = 20_000 - - const clearGracePeriod = () => { - if (gracePeriodTimer) { - clearTimeout(gracePeriodTimer) - gracePeriodTimer = null - } - } - - const scheduleReconnect = () => { - if (disposed || paused) return - clearRetry() - retryTimeoutRef.current = setTimeout(() => { - connect() - }, RECONNECT_INTERVAL_MS) - } - - 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, - }) - } - } - - const resumePolling = () => { - clearGracePeriod() - paused = false - log.debug("Resuming reconnect polling") - connect() - } - - const onVisibilityChange = () => { - if (document.visibilityState === "visible") { - resumePolling() - } else { - // Going hidden — keep polling for a grace period, then pause - clearGracePeriod() - gracePeriodTimer = setTimeout(() => { - log.debug("Grace period expired, pausing reconnect polling") - paused = true - clearRetry() - }, GRACE_PERIOD_MS) - } - } - - const onFocus = () => { - resumePolling() - } - - document.addEventListener("visibilitychange", onVisibilityChange) - window.addEventListener("focus", onFocus) - - connect() + const controller = createSocketConnectionController({ + project: state.project, + setSocket, + onMessage: handleMessage, + onDisconnected: handleDisconnected, + }) + controller.start() return () => { - disposed = true log.debug("Cleaning up socket connection") - clearRetry() - clearConnectTimeout() - clearGracePeriod() - document.removeEventListener("visibilitychange", onVisibilityChange) - window.removeEventListener("focus", onFocus) - const socket = socketRef.current - socketRef.current = null - if (socket && socket.readyState === WebSocket.OPEN) { - socket.close() - } + controller.stop() } - }, [state.project, state.permissionsGranted, api, syncTracker]) + }, [state.project, state.permissionsGranted, api, syncTracker, setSocket, handleDisconnected]) const sendMessage = useCallback((payload: unknown) => { const socket = socketRef.current diff --git a/plugins/code-link/src/utils/sockets.ts b/plugins/code-link/src/utils/sockets.ts new file mode 100644 index 000000000..c98dc38a1 --- /dev/null +++ b/plugins/code-link/src/utils/sockets.ts @@ -0,0 +1,339 @@ +import { + type CliToPluginMessage, + getPortFromHash, + isCliToPluginMessage, + type ProjectInfo, + shortProjectHash, +} from "@code-link/shared" +import { framer } from "framer-plugin" +import * as log from "./logger" + +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, + onDisconnected, +}: { + project: ProjectInfo + setSocket: (socket: WebSocket | null) => void + onMessage: (message: CliToPluginMessage, socket: WebSocket) => Promise + onDisconnected: (message: string) => void +}): SocketConnectionController { + const RECONNECT_BASE_MS = 800 + const RECONNECT_MAX_MS = 5000 + const CONNECT_TIMEOUT_MS = 1200 + const HIDDEN_GRACE_MS = 10_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 = () => + `Cannot reach CLI for ${projectName} on port ${port}. Run: npx framer-code-link ${projectShortHash}` + + const setLifecycle = (next: LifecycleState, reason: string) => { + if (lifecycle === next) return + log.debug("[connection] lifecycle transition", { from: lifecycle, to: next, reason }) + 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 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 startHiddenGracePeriod = () => { + setTimer("hiddenGrace", HIDDEN_GRACE_MS, () => { + if (isDisposed() || document.visibilityState === "visible") return + if (socketIsActive(activeSocket)) return + setLifecycle("paused", "hidden-grace-expired") + clearTimer("connectTrigger") + }) + } + + const scheduleConnect = (reason: string, delay: number) => { + if (isDisposed() || lifecycle === "paused") return + setLifecycle("active", `schedule:${reason}`) + setTimer("connectTrigger", delay, () => { + connect(`retry:${reason}`) + }) + } + + 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) => { + if (isDisposed()) return + if (lifecycle === "paused" && source.startsWith("retry:")) return + if (socketIsActive(activeSocket)) return + + const attempt = ++connectionAttempt + setLifecycle("active", `connect:${source}`) + + const socket = new WebSocket(`ws://localhost:${port}`) + const token = ++socketToken + setActiveSocket(socket) + + setTimer("connectTimeout", CONNECT_TIMEOUT_MS, () => { + if (isDisposed() || token !== socketToken) return + if (socket.readyState === WebSocket.CONNECTING) { + log.debug("WebSocket connect timeout - closing stale socket", { + port, + attempt, + timeoutMs: CONNECT_TIMEOUT_MS, + }) + closeSocket(socket) + } + }) + + const isStale = () => isDisposed() || token !== socketToken || activeSocket !== socket + + socket.onopen = async () => { + clearTimer("connectTimeout") + if (isStale()) { + closeSocket(socket) + return + } + + failureCount = 0 + hasNotifiedDisconnected = false + clearTimer("connectTrigger") + clearTimer("hiddenGrace") + setLifecycle("active", "socket-open") + 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("WebSocket error event", { + type: event.type, + port, + attempt, + project: projectName, + failureCount, + }) + } + + 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") + return + } + + startHiddenGracePeriod() + scheduleReconnect("socket-close-hidden", RECONNECT_BASE_MS) + } + } + + const hardResume = (source: ResumeSource) => { + if (isDisposed()) return + clearTimer("hiddenGrace") + clearTimer("connectTrigger") + setLifecycle("active", `resume:${source}`) + + if (activeSocket?.readyState === WebSocket.OPEN || activeSocket?.readyState === WebSocket.CONNECTING) { + return + } + + 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", "start") + + document.addEventListener("visibilitychange", onVisibilityChange) + window.addEventListener("focus", onFocus) + + if (document.visibilityState === "visible") { + connect("start") + } else { + startHiddenGracePeriod() + } + }, + stop: () => { + if (isDisposed()) return + setLifecycle("disposed", "stop") + + document.removeEventListener("visibilitychange", onVisibilityChange) + window.removeEventListener("focus", onFocus) + + clearAllTimers() + setActiveSocket(null) + + if (activeSocket) { + detachSocketHandlers(activeSocket) + closeSocket(activeSocket) + } + }, + } +} From 6e5df100a2682ebef7b008697c205cdc59d54fa6 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Wed, 18 Feb 2026 16:22:17 +0100 Subject: [PATCH 07/14] Ignore testing folders for CLI dev --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) 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 From e391f081fca29562e051c09202e605361beea3b2 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Wed, 18 Feb 2026 17:36:13 +0100 Subject: [PATCH 08/14] Cleanup --- .../code-link-cli/src/helpers/connection.ts | 6 ++-- plugins/code-link/src/App.tsx | 18 ++++++------ plugins/code-link/src/utils/sockets.ts | 28 +++++++++++++------ 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/packages/code-link-cli/src/helpers/connection.ts b/packages/code-link-cli/src/helpers/connection.ts index 786d0a3da..d90eccc70 100644 --- a/packages/code-link-cli/src/helpers/connection.ts +++ b/packages/code-link-cli/src/helpers/connection.ts @@ -72,13 +72,15 @@ 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 - if (previousActiveClient && previousActiveClient !== ws) { + + // Promote the new client. + // Close events from the previous client will be treated as stale. + if (previousActiveClient && previousActiveClient !== activeClient) { debug(`Replacing active client with conn ${connId}`) if ( previousActiveClient.readyState === READY_STATE.OPEN || diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index 01cb7ac90..b18c90cd7 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -144,14 +144,6 @@ export function App() { } }, [state.project, state.permissionsGranted, api, syncTracker]) - const setSocket = useCallback((socket: WebSocket | null) => { - socketRef.current = socket - }, []) - - const handleDisconnected = useCallback((message: string) => { - dispatch({ type: "socket-disconnected", message }) - }, []) - // Socket connection useEffect(() => { if (!state.project || !state.permissionsGranted) { @@ -162,6 +154,14 @@ export function App() { return } + const setSocket = (socket: WebSocket | null) => { + socketRef.current = socket + } + + const handleDisconnected = (message: string) => { + dispatch({ type: "socket-disconnected", message }) + } + const handleMessage = createMessageHandler({ dispatch, api, syncTracker }) const controller = createSocketConnectionController({ project: state.project, @@ -175,7 +175,7 @@ export function App() { log.debug("Cleaning up socket connection") controller.stop() } - }, [state.project, state.permissionsGranted, api, syncTracker, setSocket, handleDisconnected]) + }, [state.project, state.permissionsGranted, api, syncTracker]) const sendMessage = useCallback((payload: unknown) => { const socket = socketRef.current diff --git a/plugins/code-link/src/utils/sockets.ts b/plugins/code-link/src/utils/sockets.ts index c98dc38a1..910768fcb 100644 --- a/plugins/code-link/src/utils/sockets.ts +++ b/plugins/code-link/src/utils/sockets.ts @@ -8,6 +8,14 @@ import { 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. + * - Serializes inbound messages to avoid async race conditions. + */ type LifecycleState = "created" | "active" | "paused" | "disposed" type ResumeSource = "focus" | "visibilitychange" @@ -53,8 +61,9 @@ export function createSocketConnectionController({ const projectShortHash = shortProjectHash(project.id) const port = getPortFromHash(project.id) - const toDisconnectedMessage = () => - `Cannot reach CLI for ${projectName} on port ${port}. Run: npx framer-code-link ${projectShortHash}` + const toDisconnectedMessage = () => { + return `Cannot reach CLI for ${projectName} on port ${port}. Run: npx framer-code-link ${projectShortHash}` + } const setLifecycle = (next: LifecycleState, reason: string) => { if (lifecycle === next) return @@ -127,7 +136,7 @@ export function createSocketConnectionController({ if (isDisposed() || lifecycle === "paused") return setLifecycle("active", `schedule:${reason}`) setTimer("connectTrigger", delay, () => { - connect(`retry:${reason}`) + connect(reason, true) }) } @@ -149,13 +158,13 @@ export function createSocketConnectionController({ }) } - const connect = (source: string) => { + const connect = (source: string, isRetry = false) => { if (isDisposed()) return - if (lifecycle === "paused" && source.startsWith("retry:")) return + if (lifecycle === "paused" && isRetry) return if (socketIsActive(activeSocket)) return const attempt = ++connectionAttempt - setLifecycle("active", `connect:${source}`) + setLifecycle("active", `connect:${source}${isRetry ? ":retry" : ""}`) const socket = new WebSocket(`ws://localhost:${port}`) const token = ++socketToken @@ -328,11 +337,12 @@ export function createSocketConnectionController({ window.removeEventListener("focus", onFocus) clearAllTimers() + const socket = activeSocket setActiveSocket(null) - if (activeSocket) { - detachSocketHandlers(activeSocket) - closeSocket(activeSocket) + if (socket) { + detachSocketHandlers(socket) + closeSocket(socket) } }, } From 8435ffc36b318439bc994adbc938e85042210ae2 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Wed, 18 Feb 2026 19:48:31 +0100 Subject: [PATCH 09/14] Improve initial connections --- plugins/code-link/src/App.tsx | 7 +- plugins/code-link/src/main.tsx | 10 ++- plugins/code-link/src/utils/sockets.ts | 107 +++++++++++++++++++------ 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx index b18c90cd7..8e99e9bbe 100644 --- a/plugins/code-link/src/App.tsx +++ b/plugins/code-link/src/App.tsx @@ -53,7 +53,8 @@ function reducer(state: State, action: Action): State { return { ...state, permissionsGranted: action.granted, - mode: action.granted ? state.mode : "info", + // When permissions become available, hide Info while we attempt socket connect. + mode: action.granted ? (state.mode === "info" ? "loading" : state.mode) : "info", } case "set-mode": return { @@ -161,12 +162,16 @@ export function App() { const handleDisconnected = (message: string) => { dispatch({ type: "socket-disconnected", message }) } + const handleConnected = () => { + dispatch({ type: "set-mode", mode: "syncing" }) + } const handleMessage = createMessageHandler({ dispatch, api, syncTracker }) const controller = createSocketConnectionController({ project: state.project, setSocket, onMessage: handleMessage, + onConnected: handleConnected, onDisconnected: handleDisconnected, }) controller.start() 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 index 910768fcb..6f439486d 100644 --- a/plugins/code-link/src/utils/sockets.ts +++ b/plugins/code-link/src/utils/sockets.ts @@ -14,10 +14,8 @@ import * as log from "./logger" * - 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. - * - Serializes inbound messages to avoid async race conditions. */ type LifecycleState = "created" | "active" | "paused" | "disposed" - type ResumeSource = "focus" | "visibilitychange" type TimerName = "connectTrigger" | "connectTimeout" | "hiddenGrace" @@ -30,17 +28,20 @@ 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 = 800 + const RECONNECT_BASE_MS = 500 const RECONNECT_MAX_MS = 5000 - const CONNECT_TIMEOUT_MS = 1200 - const HIDDEN_GRACE_MS = 10_000 + 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 @@ -65,9 +66,8 @@ export function createSocketConnectionController({ return `Cannot reach CLI for ${projectName} on port ${port}. Run: npx framer-code-link ${projectShortHash}` } - const setLifecycle = (next: LifecycleState, reason: string) => { + const setLifecycle = (next: LifecycleState) => { if (lifecycle === next) return - log.debug("[connection] lifecycle transition", { from: lifecycle, to: next, reason }) lifecycle = next } @@ -116,6 +116,15 @@ export function createSocketConnectionController({ 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) @@ -123,18 +132,44 @@ export function createSocketConnectionController({ 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() || document.visibilityState === "visible") return + if (isDisposed()) return + if (document.visibilityState === "visible") return if (socketIsActive(activeSocket)) return - setLifecycle("paused", "hidden-grace-expired") + setLifecycle("paused") clearTimer("connectTrigger") }) } const scheduleConnect = (reason: string, delay: number) => { if (isDisposed() || lifecycle === "paused") return - setLifecycle("active", `schedule:${reason}`) + setLifecycle("active") setTimer("connectTrigger", delay, () => { connect(reason, true) }) @@ -161,22 +196,39 @@ export function createSocketConnectionController({ const connect = (source: string, isRetry = false) => { if (isDisposed()) return if (lifecycle === "paused" && isRetry) return - if (socketIsActive(activeSocket)) 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", `connect:${source}${isRetry ? ":retry" : ""}`) + 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", CONNECT_TIMEOUT_MS, () => { + setTimer("connectTimeout", connectTimeoutMs, () => { if (isDisposed() || token !== socketToken) return if (socket.readyState === WebSocket.CONNECTING) { log.debug("WebSocket connect timeout - closing stale socket", { port, attempt, - timeoutMs: CONNECT_TIMEOUT_MS, + timeoutMs: connectTimeoutMs, }) closeSocket(socket) } @@ -191,11 +243,12 @@ export function createSocketConnectionController({ return } + onConnected() failureCount = 0 hasNotifiedDisconnected = false clearTimer("connectTrigger") clearTimer("hiddenGrace") - setLifecycle("active", "socket-open") + setLifecycle("active") log.debug("WebSocket connected, sending handshake", { port, attempt, project: projectName }) try { @@ -237,12 +290,13 @@ export function createSocketConnectionController({ socket.onerror = event => { if (isStale()) return - log.debug("WebSocket error event", { + log.debug("[connection] WebSocket error event (expected while reconnecting)", { type: event.type, port, attempt, project: projectName, failureCount, + visibilityState: document.visibilityState, }) } @@ -274,7 +328,7 @@ export function createSocketConnectionController({ if (isDisposed()) return if (document.visibilityState === "visible") { - scheduleReconnect("socket-close-visible") + scheduleReconnect("socket-close-visible", computeVisibleReconnectDelay()) return } @@ -287,11 +341,16 @@ export function createSocketConnectionController({ if (isDisposed()) return clearTimer("hiddenGrace") clearTimer("connectTrigger") - setLifecycle("active", `resume:${source}`) + setLifecycle("active") - if (activeSocket?.readyState === WebSocket.OPEN || activeSocket?.readyState === WebSocket.CONNECTING) { + const socket = activeSocket + if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) { return } + resetReconnectBackoff(`resume:${source}`) + if (socket) { + clearSocket(socket) + } connect(`resume:${source}`) } @@ -318,7 +377,7 @@ export function createSocketConnectionController({ return { start: () => { if (lifecycle !== "created") return - setLifecycle("active", "start") + setLifecycle("active") document.addEventListener("visibilitychange", onVisibilityChange) window.addEventListener("focus", onFocus) @@ -331,18 +390,18 @@ export function createSocketConnectionController({ }, stop: () => { if (isDisposed()) return - setLifecycle("disposed", "stop") + setLifecycle("disposed") document.removeEventListener("visibilitychange", onVisibilityChange) window.removeEventListener("focus", onFocus) clearAllTimers() const socket = activeSocket - setActiveSocket(null) if (socket) { - detachSocketHandlers(socket) - closeSocket(socket) + clearSocket(socket) + } else { + setActiveSocket(null) } }, } From 1b8374e74f79b8d87a71a5aabde5e23171ab251b Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Wed, 18 Feb 2026 20:39:37 +0100 Subject: [PATCH 10/14] Clarify comment --- packages/code-link-cli/src/helpers/connection.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/code-link-cli/src/helpers/connection.ts b/packages/code-link-cli/src/helpers/connection.ts index d90eccc70..230060c0e 100644 --- a/packages/code-link-cli/src/helpers/connection.ts +++ b/packages/code-link-cli/src/helpers/connection.ts @@ -78,8 +78,7 @@ export function initConnection(port: number): Promise { const previousActiveClient = activeClient activeClient = ws - // Promote the new client. - // Close events from the previous client will be treated as stale. + // 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 ( From 7ffd454df4f0990c4e8884d2b1882d84fd08114d Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 19 Feb 2026 09:25:20 +0100 Subject: [PATCH 11/14] Log deletes from local after confirm --- packages/code-link-cli/src/controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index ca025edd6..2ebe4c3b2 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -922,6 +922,7 @@ async function executeEffect( for (const fileName of confirmedFiles) { hashTracker.forget(fileName) fileMetadataCache.recordDelete(fileName) + fileDelete(fileName) } if (confirmedFiles.length > 0 && syncState.socket) { From 4af1e917afa6edc870887d7e912055c251be6cb1 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 19 Feb 2026 09:52:12 +0100 Subject: [PATCH 12/14] Scrolling for Plugin lists --- plugins/code-link/src/App.css | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/code-link/src/App.css b/plugins/code-link/src/App.css index c71c29162..3629169d6 100644 --- a/plugins/code-link/src/App.css +++ b/plugins/code-link/src/App.css @@ -223,6 +223,7 @@ html, text-overflow: ellipsis; white-space: nowrap; overflow: hidden; + flex-shrink: 0; grid-template-columns: 3fr 1fr 1fr; } From 1b3a72f02ca22552dbba510e6f3f606b7d4e0aa1 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 19 Feb 2026 09:52:25 +0100 Subject: [PATCH 13/14] Log when files are added during sync --- packages/code-link-cli/src/controller.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index 2ebe4c3b2..4111bac2c 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -304,11 +304,15 @@ 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`), { - type: "WRITE_FILES", - files: safeWrites, - silent: true, - }) + effects.push( + log("debug", `Applying ${safeWrites.length} safe writes`), + log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`), + { + type: "WRITE_FILES", + files: safeWrites, + silent: true, + } + ) } // Upload local-only files From 4aa839cd5a78e16d12aa7aff96a04a445d3da300 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 19 Feb 2026 10:40:07 +0100 Subject: [PATCH 14/14] Publish --- packages/code-link-cli/package.json | 2 +- packages/code-link-cli/src/controller.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) 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 4111bac2c..21f0e5939 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -304,15 +304,15 @@ 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`), - log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`), - { - type: "WRITE_FILES", - files: safeWrites, - silent: true, - } - ) + 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, + }) } // Upload local-only files