framer-code-link 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +71 -21
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -509,6 +509,7 @@ function initConnection(port) {
509
509
  wss.on("listening", () => {
510
510
  isReady = true;
511
511
  debug(`WebSocket server listening on port ${port}`);
512
+ let activeClient = null;
512
513
  wss.on("connection", (ws) => {
513
514
  const connId = ++connectionId;
514
515
  let handshakeReceived = false;
@@ -519,8 +520,15 @@ function initConnection(port) {
519
520
  if (message.type === "handshake") {
520
521
  debug(`Received handshake (conn ${connId})`);
521
522
  handshakeReceived = true;
523
+ const previousActiveClient = activeClient;
524
+ activeClient = ws;
525
+ if (previousActiveClient && previousActiveClient !== activeClient) {
526
+ debug(`Replacing active client with conn ${connId}`);
527
+ if (previousActiveClient.readyState === READY_STATE.OPEN || previousActiveClient.readyState === READY_STATE.CONNECTING) previousActiveClient.close();
528
+ }
522
529
  handlers.onHandshake?.(ws, message);
523
- } else if (handshakeReceived) handlers.onMessage?.(message);
530
+ } else if (handshakeReceived && activeClient === ws) handlers.onMessage?.(message);
531
+ else if (handshakeReceived) debug(`Ignoring ${message.type} from stale client (conn ${connId})`);
524
532
  else debug(`Ignoring ${message.type} before handshake (conn ${connId})`);
525
533
  } catch (err) {
526
534
  error(`Failed to parse message:`, err);
@@ -528,7 +536,10 @@ function initConnection(port) {
528
536
  });
529
537
  ws.on("close", (code, reason) => {
530
538
  debug(`Client disconnected (code: ${code}, reason: ${reason.toString()})`);
531
- handlers.onDisconnect?.();
539
+ if (activeClient === ws) {
540
+ activeClient = null;
541
+ handlers.onDisconnect?.(ws);
542
+ } else debug(`Ignoring disconnect from stale client (conn ${connId})`);
532
543
  });
533
544
  ws.on("error", (err) => {
534
545
  error(`WebSocket error:`, err);
@@ -680,7 +691,7 @@ async function loadPersistedState(projectDir) {
680
691
  if (normalizedName !== fileName) debug(`Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility`);
681
692
  result.set(normalizedName, state);
682
693
  }
683
- debug(`Loaded persisted state for ${result.size} files`);
694
+ debug(`Loaded persisted state for ${pluralize(result.size, "file")}`);
684
695
  return result;
685
696
  } catch (err) {
686
697
  if (err.code === "ENOENT") {
@@ -702,7 +713,7 @@ async function savePersistedState(projectDir, state) {
702
713
  };
703
714
  try {
704
715
  await fs.writeFile(statePath, JSON.stringify(persistedState, null, 2));
705
- debug(`Saved persisted state for ${state.size} files`);
716
+ debug(`Saved persisted state for ${pluralize(state.size, "file")}`);
706
717
  } catch (err) {
707
718
  warn("Failed to save persisted state:", err);
708
719
  }
@@ -773,7 +784,7 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
773
784
  const preferRemote = options.preferRemote ?? false;
774
785
  const persistedState = options.persistedState;
775
786
  const getPersistedState = (fileName) => persistedState?.get(fileKeyForLookup(fileName));
776
- debug(`Detecting conflicts for ${String(remoteFiles.length)} remote files`);
787
+ debug(`Detecting conflicts for ${pluralize(remoteFiles.length, "remote file")}`);
777
788
  const localFiles = await listFiles(filesDir);
778
789
  const localFileMap = new Map(localFiles.map((f) => [fileKeyForLookup(f.name), f]));
779
790
  const remoteFileMap = new Map(remoteFiles.map((f) => {
@@ -927,7 +938,7 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
927
938
  * CRITICAL: Update hashTracker BEFORE writing to disk
928
939
  */
929
940
  async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
930
- debug(`Writing ${files.length} remote files`);
941
+ debug(`Writing ${pluralize(files.length, "remote file")}`);
931
942
  for (const file of files) try {
932
943
  const normalized = resolveRemoteReference(filesDir, file.name);
933
944
  const fullPath = normalized.absolutePath;
@@ -1994,7 +2005,8 @@ async function getProjectHashFromCwd() {
1994
2005
  return null;
1995
2006
  }
1996
2007
  }
1997
- async function findOrCreateProjectDirectory(projectHash, projectName, explicitDirectory) {
2008
+ async function findOrCreateProjectDirectory(options) {
2009
+ const { projectHash, projectName, explicitDirectory, baseDirectory } = options;
1998
2010
  if (explicitDirectory) {
1999
2011
  const resolved = path.resolve(explicitDirectory);
2000
2012
  await fs.mkdir(path.join(resolved, "files"), { recursive: true });
@@ -2003,7 +2015,7 @@ async function findOrCreateProjectDirectory(projectHash, projectName, explicitDi
2003
2015
  created: false
2004
2016
  };
2005
2017
  }
2006
- const cwd = process.cwd();
2018
+ const cwd = baseDirectory ?? process.cwd();
2007
2019
  const existing = await findExistingProjectDirectory(cwd, projectHash);
2008
2020
  if (existing) return {
2009
2021
  directory: existing,
@@ -2013,7 +2025,7 @@ async function findOrCreateProjectDirectory(projectHash, projectName, explicitDi
2013
2025
  const directoryName = toDirectoryName(projectName);
2014
2026
  const pkgName = toPackageName(projectName);
2015
2027
  const shortId = shortProjectHash(projectHash);
2016
- const projectDirectory = path.join(cwd, directoryName || shortId);
2028
+ const { directory: projectDirectory, nameCollision } = await findAvailableDirectory(cwd, directoryName || `project-${shortId}`, shortId);
2017
2029
  await fs.mkdir(path.join(projectDirectory, "files"), { recursive: true });
2018
2030
  const pkg = {
2019
2031
  name: pkgName || shortId,
@@ -2025,9 +2037,29 @@ async function findOrCreateProjectDirectory(projectHash, projectName, explicitDi
2025
2037
  await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 2));
2026
2038
  return {
2027
2039
  directory: projectDirectory,
2028
- created: true
2040
+ created: true,
2041
+ nameCollision
2029
2042
  };
2030
2043
  }
2044
+ /**
2045
+ * Returns a directory path that doesn't collide with an existing project.
2046
+ * Tries the bare name first, falls back to name-{shortId} if taken.
2047
+ */
2048
+ async function findAvailableDirectory(baseDir, name, shortId) {
2049
+ const candidate = path.join(baseDir, name);
2050
+ try {
2051
+ await fs.access(candidate);
2052
+ return {
2053
+ directory: path.join(baseDir, `${name}-${shortId}`),
2054
+ nameCollision: true
2055
+ };
2056
+ } catch {
2057
+ return {
2058
+ directory: candidate,
2059
+ nameCollision: false
2060
+ };
2061
+ }
2062
+ }
2031
2063
  async function findExistingProjectDirectory(baseDirectory, projectHash) {
2032
2064
  if (await matchesProject(path.join(baseDirectory, "package.json"), projectHash)) return baseDirectory;
2033
2065
  const entries = await fs.readdir(baseDirectory, { withFileTypes: true });
@@ -2141,7 +2173,7 @@ function transition(state, event) {
2141
2173
  effects
2142
2174
  };
2143
2175
  }
2144
- effects.push(log("debug", `Received file list: ${event.files.length} files`));
2176
+ effects.push(log("debug", `Received file list: ${pluralize(event.files.length, "file")}`));
2145
2177
  effects.push({
2146
2178
  type: "DETECT_CONFLICTS",
2147
2179
  remoteFiles: event.files
@@ -2163,13 +2195,13 @@ function transition(state, event) {
2163
2195
  };
2164
2196
  }
2165
2197
  const { conflicts, safeWrites, localOnly } = event;
2166
- if (safeWrites.length > 0) effects.push(log("debug", `Applying ${safeWrites.length} safe writes`), {
2198
+ if (safeWrites.length > 0) effects.push(log("debug", `Applying ${safeWrites.length} safe writes`), log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`), {
2167
2199
  type: "WRITE_FILES",
2168
2200
  files: safeWrites,
2169
2201
  silent: true
2170
2202
  });
2171
2203
  if (localOnly.length > 0) {
2172
- effects.push(log("debug", `Uploading ${localOnly.length} local-only files`));
2204
+ effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`));
2173
2205
  for (const file of localOnly) effects.push({
2174
2206
  type: "SEND_MESSAGE",
2175
2207
  payload: {
@@ -2456,9 +2488,14 @@ async function executeEffect(effect, context) {
2456
2488
  case "INIT_WORKSPACE":
2457
2489
  if (!config.projectDir) {
2458
2490
  const projectName = config.explicitName ?? effect.projectInfo.projectName;
2459
- const directoryInfo = await findOrCreateProjectDirectory(config.projectHash, projectName, config.explicitDirectory);
2491
+ const directoryInfo = await findOrCreateProjectDirectory({
2492
+ projectHash: config.projectHash,
2493
+ projectName,
2494
+ explicitDirectory: config.explicitDirectory
2495
+ });
2460
2496
  config.projectDir = directoryInfo.directory;
2461
2497
  config.projectDirCreated = directoryInfo.created;
2498
+ if (directoryInfo.nameCollision) warn(`Folder ${projectName} already exists`);
2462
2499
  config.filesDir = `${config.projectDir}/files`;
2463
2500
  debug(`Files directory: ${config.filesDir}`);
2464
2501
  await fs.mkdir(config.filesDir, { recursive: true });
@@ -2467,7 +2504,7 @@ async function executeEffect(effect, context) {
2467
2504
  case "LOAD_PERSISTED_STATE":
2468
2505
  if (config.projectDir) {
2469
2506
  await fileMetadataCache.initialize(config.projectDir);
2470
- debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
2507
+ debug(`Loaded persisted metadata for ${pluralize(fileMetadataCache.size(), "file")}`);
2471
2508
  }
2472
2509
  return [];
2473
2510
  case "LIST_LOCAL_FILES": {
@@ -2586,6 +2623,7 @@ async function executeEffect(effect, context) {
2586
2623
  for (const fileName of confirmedFiles) {
2587
2624
  hashTracker.forget(fileName);
2588
2625
  fileMetadataCache.recordDelete(fileName);
2626
+ fileDelete(fileName);
2589
2627
  }
2590
2628
  if (confirmedFiles.length > 0 && syncState.socket) await sendMessage(syncState.socket, {
2591
2629
  type: "file-delete",
@@ -2604,7 +2642,7 @@ async function executeEffect(effect, context) {
2604
2642
  if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
2605
2643
  if (wasDisconnected) {
2606
2644
  if (didShowDisconnect()) {
2607
- success(`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2645
+ success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2608
2646
  status("Watching for changes...");
2609
2647
  }
2610
2648
  resetDisconnectState();
@@ -2614,9 +2652,9 @@ async function executeEffect(effect, context) {
2614
2652
  const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
2615
2653
  if (effect.totalCount === 0 && relativeDirectory) if (config.projectDirCreated) success(`Created ${relativeDirectory} folder`);
2616
2654
  else success(`Syncing to ${relativeDirectory} folder`);
2617
- else if (relativeDirectory && config.projectDirCreated) success(`Synced into ${relativeDirectory} (${effect.updatedCount} files added)`);
2618
- else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${effect.updatedCount} files updated, ${effect.unchangedCount} unchanged)`);
2619
- else success(`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2655
+ else if (relativeDirectory && config.projectDirCreated) success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`);
2656
+ else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`);
2657
+ else success(`Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2620
2658
  if (config.projectDirCreated && config.projectDir) tryGitInit(config.projectDir);
2621
2659
  status("Watching for changes...");
2622
2660
  return [];
@@ -2679,6 +2717,14 @@ async function start(config) {
2679
2717
  }
2680
2718
  (async () => {
2681
2719
  cancelDisconnectMessage();
2720
+ if (syncState.mode !== "disconnected") {
2721
+ if (syncState.socket === client) {
2722
+ debug(`Ignoring duplicate handshake from active socket in ${syncState.mode} mode`);
2723
+ return;
2724
+ }
2725
+ debug(`New handshake received in ${syncState.mode} mode, resetting sync state`);
2726
+ await processEvent({ type: "DISCONNECT" });
2727
+ }
2682
2728
  if (!wasRecentlyDisconnected() && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
2683
2729
  await processEvent({
2684
2730
  type: "HANDSHAKE",
@@ -2709,7 +2755,7 @@ async function start(config) {
2709
2755
  event = { type: "REQUEST_FILES" };
2710
2756
  break;
2711
2757
  case "file-list":
2712
- debug(`Received file list: ${message.files.length} files`);
2758
+ debug(`Received file list: ${pluralize(message.files.length, "file")}`);
2713
2759
  event = {
2714
2760
  type: "REMOTE_FILE_LIST",
2715
2761
  files: message.files
@@ -2785,7 +2831,11 @@ async function start(config) {
2785
2831
  }
2786
2832
  })();
2787
2833
  });
2788
- connection.on("disconnect", () => {
2834
+ connection.on("disconnect", (client) => {
2835
+ if (syncState.socket !== client) {
2836
+ debug("[STATE] Ignoring disconnect from stale socket");
2837
+ return;
2838
+ }
2789
2839
  scheduleDisconnectMessage(() => {
2790
2840
  status("Disconnected, waiting to reconnect...");
2791
2841
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "CLI tool for syncing Framer code components - controller-centric architecture",
5
5
  "main": "dist/index.mjs",
6
6
  "type": "module",