framer-code-link 0.15.0 → 0.17.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 +79 -25
  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,17 @@ 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`), {
2167
- type: "WRITE_FILES",
2168
- files: safeWrites,
2169
- silent: true
2170
- });
2198
+ if (safeWrites.length > 0) {
2199
+ effects.push(log("debug", `Applying ${safeWrites.length} safe writes`));
2200
+ if (wasRecentlyDisconnected()) effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`));
2201
+ effects.push({
2202
+ type: "WRITE_FILES",
2203
+ files: safeWrites,
2204
+ silent: true
2205
+ });
2206
+ }
2171
2207
  if (localOnly.length > 0) {
2172
- effects.push(log("debug", `Uploading ${localOnly.length} local-only files`));
2208
+ effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`));
2173
2209
  for (const file of localOnly) effects.push({
2174
2210
  type: "SEND_MESSAGE",
2175
2211
  payload: {
@@ -2456,9 +2492,14 @@ async function executeEffect(effect, context) {
2456
2492
  case "INIT_WORKSPACE":
2457
2493
  if (!config.projectDir) {
2458
2494
  const projectName = config.explicitName ?? effect.projectInfo.projectName;
2459
- const directoryInfo = await findOrCreateProjectDirectory(config.projectHash, projectName, config.explicitDirectory);
2495
+ const directoryInfo = await findOrCreateProjectDirectory({
2496
+ projectHash: config.projectHash,
2497
+ projectName,
2498
+ explicitDirectory: config.explicitDirectory
2499
+ });
2460
2500
  config.projectDir = directoryInfo.directory;
2461
2501
  config.projectDirCreated = directoryInfo.created;
2502
+ if (directoryInfo.nameCollision) warn(`Folder ${projectName} already exists`);
2462
2503
  config.filesDir = `${config.projectDir}/files`;
2463
2504
  debug(`Files directory: ${config.filesDir}`);
2464
2505
  await fs.mkdir(config.filesDir, { recursive: true });
@@ -2467,7 +2508,7 @@ async function executeEffect(effect, context) {
2467
2508
  case "LOAD_PERSISTED_STATE":
2468
2509
  if (config.projectDir) {
2469
2510
  await fileMetadataCache.initialize(config.projectDir);
2470
- debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
2511
+ debug(`Loaded persisted metadata for ${pluralize(fileMetadataCache.size(), "file")}`);
2471
2512
  }
2472
2513
  return [];
2473
2514
  case "LIST_LOCAL_FILES": {
@@ -2586,6 +2627,7 @@ async function executeEffect(effect, context) {
2586
2627
  for (const fileName of confirmedFiles) {
2587
2628
  hashTracker.forget(fileName);
2588
2629
  fileMetadataCache.recordDelete(fileName);
2630
+ fileDelete(fileName);
2589
2631
  }
2590
2632
  if (confirmedFiles.length > 0 && syncState.socket) await sendMessage(syncState.socket, {
2591
2633
  type: "file-delete",
@@ -2604,7 +2646,7 @@ async function executeEffect(effect, context) {
2604
2646
  if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
2605
2647
  if (wasDisconnected) {
2606
2648
  if (didShowDisconnect()) {
2607
- success(`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2649
+ success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2608
2650
  status("Watching for changes...");
2609
2651
  }
2610
2652
  resetDisconnectState();
@@ -2614,9 +2656,9 @@ async function executeEffect(effect, context) {
2614
2656
  const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
2615
2657
  if (effect.totalCount === 0 && relativeDirectory) if (config.projectDirCreated) success(`Created ${relativeDirectory} folder`);
2616
2658
  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)`);
2659
+ else if (relativeDirectory && config.projectDirCreated) success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`);
2660
+ else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`);
2661
+ else success(`Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2620
2662
  if (config.projectDirCreated && config.projectDir) tryGitInit(config.projectDir);
2621
2663
  status("Watching for changes...");
2622
2664
  return [];
@@ -2679,6 +2721,14 @@ async function start(config) {
2679
2721
  }
2680
2722
  (async () => {
2681
2723
  cancelDisconnectMessage();
2724
+ if (syncState.mode !== "disconnected") {
2725
+ if (syncState.socket === client) {
2726
+ debug(`Ignoring duplicate handshake from active socket in ${syncState.mode} mode`);
2727
+ return;
2728
+ }
2729
+ debug(`New handshake received in ${syncState.mode} mode, resetting sync state`);
2730
+ await processEvent({ type: "DISCONNECT" });
2731
+ }
2682
2732
  if (!wasRecentlyDisconnected() && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
2683
2733
  await processEvent({
2684
2734
  type: "HANDSHAKE",
@@ -2709,7 +2759,7 @@ async function start(config) {
2709
2759
  event = { type: "REQUEST_FILES" };
2710
2760
  break;
2711
2761
  case "file-list":
2712
- debug(`Received file list: ${message.files.length} files`);
2762
+ debug(`Received file list: ${pluralize(message.files.length, "file")}`);
2713
2763
  event = {
2714
2764
  type: "REMOTE_FILE_LIST",
2715
2765
  files: message.files
@@ -2785,7 +2835,11 @@ async function start(config) {
2785
2835
  }
2786
2836
  })();
2787
2837
  });
2788
- connection.on("disconnect", () => {
2838
+ connection.on("disconnect", (client) => {
2839
+ if (syncState.socket !== client) {
2840
+ debug("[STATE] Ignoring disconnect from stale socket");
2841
+ return;
2842
+ }
2789
2843
  scheduleDisconnectMessage(() => {
2790
2844
  status("Disconnected, waiting to reconnect...");
2791
2845
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "CLI tool for syncing Framer code components - controller-centric architecture",
5
5
  "main": "dist/index.mjs",
6
6
  "type": "module",