framer-code-link 0.14.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 +119 -67
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -5,10 +5,10 @@ import fs from "fs/promises";
5
5
  import path from "path";
6
6
  import { WebSocketServer } from "ws";
7
7
  import { createHash } from "crypto";
8
- import { setupTypeAcquisition } from "@typescript/ata";
9
- import ts from "typescript";
10
8
  import { execSync } from "child_process";
11
9
  import fs$1 from "fs";
10
+ import { setupTypeAcquisition } from "@typescript/ata";
11
+ import ts from "typescript";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import chokidar from "chokidar";
14
14
 
@@ -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;
@@ -1002,49 +1013,6 @@ function isSupportedExtension(fileName) {
1002
1013
  return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext));
1003
1014
  }
1004
1015
 
1005
- //#endregion
1006
- //#region src/utils/imports.ts
1007
- /**
1008
- * Extract npm and URL-based imports from source code.
1009
- */
1010
- function extractImports(code) {
1011
- const imports = [];
1012
- const seen = /* @__PURE__ */ new Set();
1013
- const npmRegex = /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]([^./][^'"]+)['"]/g;
1014
- const urlRegex = /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]https?:\/\/[^'"]+['"]/g;
1015
- let match;
1016
- while ((match = npmRegex.exec(code)) !== null) {
1017
- const pkgName = match[1];
1018
- const normalized = pkgName.startsWith("@") ? pkgName.split("/").slice(0, 2).join("/") : pkgName.split("/")[0];
1019
- if (!seen.has(normalized)) {
1020
- seen.add(normalized);
1021
- imports.push({
1022
- type: "npm",
1023
- name: normalized,
1024
- raw: match[0]
1025
- });
1026
- }
1027
- }
1028
- while ((match = urlRegex.exec(code)) !== null) {
1029
- const pkgName = extractPackageFromUrl(match[0]);
1030
- if (pkgName && !seen.has(pkgName)) {
1031
- seen.add(pkgName);
1032
- imports.push({
1033
- type: "url",
1034
- name: pkgName,
1035
- raw: match[0]
1036
- });
1037
- }
1038
- }
1039
- return imports;
1040
- }
1041
- /**
1042
- * Attempt to derive an npm-style package specifier from a URL import.
1043
- */
1044
- function extractPackageFromUrl(url) {
1045
- return /\/(@?[^@/]+(?:\/[^@/]+)?)/.exec(url)?.[1] ?? null;
1046
- }
1047
-
1048
1016
  //#endregion
1049
1017
  //#region src/helpers/git.ts
1050
1018
  /**
@@ -1124,6 +1092,49 @@ function tryGitInit(projectDir) {
1124
1092
  }
1125
1093
  }
1126
1094
 
1095
+ //#endregion
1096
+ //#region src/utils/imports.ts
1097
+ /**
1098
+ * Extract npm and URL-based imports from source code.
1099
+ */
1100
+ function extractImports(code) {
1101
+ const imports = [];
1102
+ const seen = /* @__PURE__ */ new Set();
1103
+ const npmRegex = /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]([^./][^'"]+)['"]/g;
1104
+ const urlRegex = /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]https?:\/\/[^'"]+['"]/g;
1105
+ let match;
1106
+ while ((match = npmRegex.exec(code)) !== null) {
1107
+ const pkgName = match[1];
1108
+ const normalized = pkgName.startsWith("@") ? pkgName.split("/").slice(0, 2).join("/") : pkgName.split("/")[0];
1109
+ if (!seen.has(normalized)) {
1110
+ seen.add(normalized);
1111
+ imports.push({
1112
+ type: "npm",
1113
+ name: normalized,
1114
+ raw: match[0]
1115
+ });
1116
+ }
1117
+ }
1118
+ while ((match = urlRegex.exec(code)) !== null) {
1119
+ const pkgName = extractPackageFromUrl(match[0]);
1120
+ if (pkgName && !seen.has(pkgName)) {
1121
+ seen.add(pkgName);
1122
+ imports.push({
1123
+ type: "url",
1124
+ name: pkgName,
1125
+ raw: match[0]
1126
+ });
1127
+ }
1128
+ }
1129
+ return imports;
1130
+ }
1131
+ /**
1132
+ * Attempt to derive an npm-style package specifier from a URL import.
1133
+ */
1134
+ function extractPackageFromUrl(url) {
1135
+ return /\/(@?[^@/]+(?:\/[^@/]+)?)/.exec(url)?.[1] ?? null;
1136
+ }
1137
+
1127
1138
  //#endregion
1128
1139
  //#region src/helpers/skills.ts
1129
1140
  /**
@@ -1346,7 +1357,6 @@ var Installer = class {
1346
1357
  this.ensureSkills(),
1347
1358
  this.ensureGitignore()
1348
1359
  ]);
1349
- tryGitInit(this.projectDir);
1350
1360
  Promise.resolve().then(async () => {
1351
1361
  await this.ensureReact18Types();
1352
1362
  const coreImports = CORE_LIBRARIES.map((lib) => `import "${lib}";`).join("\n");
@@ -1508,6 +1518,8 @@ declare module "*.json"
1508
1518
  } catch {}
1509
1519
  const content = [
1510
1520
  "node_modules/",
1521
+ ".DS_Store",
1522
+ "*.local",
1511
1523
  "",
1512
1524
  "# Framer Code Link",
1513
1525
  ".framer-sync-state.json",
@@ -1993,7 +2005,8 @@ async function getProjectHashFromCwd() {
1993
2005
  return null;
1994
2006
  }
1995
2007
  }
1996
- async function findOrCreateProjectDirectory(projectHash, projectName, explicitDirectory) {
2008
+ async function findOrCreateProjectDirectory(options) {
2009
+ const { projectHash, projectName, explicitDirectory, baseDirectory } = options;
1997
2010
  if (explicitDirectory) {
1998
2011
  const resolved = path.resolve(explicitDirectory);
1999
2012
  await fs.mkdir(path.join(resolved, "files"), { recursive: true });
@@ -2002,7 +2015,7 @@ async function findOrCreateProjectDirectory(projectHash, projectName, explicitDi
2002
2015
  created: false
2003
2016
  };
2004
2017
  }
2005
- const cwd = process.cwd();
2018
+ const cwd = baseDirectory ?? process.cwd();
2006
2019
  const existing = await findExistingProjectDirectory(cwd, projectHash);
2007
2020
  if (existing) return {
2008
2021
  directory: existing,
@@ -2012,7 +2025,7 @@ async function findOrCreateProjectDirectory(projectHash, projectName, explicitDi
2012
2025
  const directoryName = toDirectoryName(projectName);
2013
2026
  const pkgName = toPackageName(projectName);
2014
2027
  const shortId = shortProjectHash(projectHash);
2015
- const projectDirectory = path.join(cwd, directoryName || shortId);
2028
+ const { directory: projectDirectory, nameCollision } = await findAvailableDirectory(cwd, directoryName || `project-${shortId}`, shortId);
2016
2029
  await fs.mkdir(path.join(projectDirectory, "files"), { recursive: true });
2017
2030
  const pkg = {
2018
2031
  name: pkgName || shortId,
@@ -2024,9 +2037,29 @@ async function findOrCreateProjectDirectory(projectHash, projectName, explicitDi
2024
2037
  await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 2));
2025
2038
  return {
2026
2039
  directory: projectDirectory,
2027
- created: true
2040
+ created: true,
2041
+ nameCollision
2028
2042
  };
2029
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
+ }
2030
2063
  async function findExistingProjectDirectory(baseDirectory, projectHash) {
2031
2064
  if (await matchesProject(path.join(baseDirectory, "package.json"), projectHash)) return baseDirectory;
2032
2065
  const entries = await fs.readdir(baseDirectory, { withFileTypes: true });
@@ -2140,7 +2173,7 @@ function transition(state, event) {
2140
2173
  effects
2141
2174
  };
2142
2175
  }
2143
- effects.push(log("debug", `Received file list: ${event.files.length} files`));
2176
+ effects.push(log("debug", `Received file list: ${pluralize(event.files.length, "file")}`));
2144
2177
  effects.push({
2145
2178
  type: "DETECT_CONFLICTS",
2146
2179
  remoteFiles: event.files
@@ -2162,13 +2195,13 @@ function transition(state, event) {
2162
2195
  };
2163
2196
  }
2164
2197
  const { conflicts, safeWrites, localOnly } = event;
2165
- 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`), {
2166
2199
  type: "WRITE_FILES",
2167
2200
  files: safeWrites,
2168
2201
  silent: true
2169
2202
  });
2170
2203
  if (localOnly.length > 0) {
2171
- effects.push(log("debug", `Uploading ${localOnly.length} local-only files`));
2204
+ effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`));
2172
2205
  for (const file of localOnly) effects.push({
2173
2206
  type: "SEND_MESSAGE",
2174
2207
  payload: {
@@ -2455,9 +2488,14 @@ async function executeEffect(effect, context) {
2455
2488
  case "INIT_WORKSPACE":
2456
2489
  if (!config.projectDir) {
2457
2490
  const projectName = config.explicitName ?? effect.projectInfo.projectName;
2458
- 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
+ });
2459
2496
  config.projectDir = directoryInfo.directory;
2460
2497
  config.projectDirCreated = directoryInfo.created;
2498
+ if (directoryInfo.nameCollision) warn(`Folder ${projectName} already exists`);
2461
2499
  config.filesDir = `${config.projectDir}/files`;
2462
2500
  debug(`Files directory: ${config.filesDir}`);
2463
2501
  await fs.mkdir(config.filesDir, { recursive: true });
@@ -2466,7 +2504,7 @@ async function executeEffect(effect, context) {
2466
2504
  case "LOAD_PERSISTED_STATE":
2467
2505
  if (config.projectDir) {
2468
2506
  await fileMetadataCache.initialize(config.projectDir);
2469
- debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
2507
+ debug(`Loaded persisted metadata for ${pluralize(fileMetadataCache.size(), "file")}`);
2470
2508
  }
2471
2509
  return [];
2472
2510
  case "LIST_LOCAL_FILES": {
@@ -2585,6 +2623,7 @@ async function executeEffect(effect, context) {
2585
2623
  for (const fileName of confirmedFiles) {
2586
2624
  hashTracker.forget(fileName);
2587
2625
  fileMetadataCache.recordDelete(fileName);
2626
+ fileDelete(fileName);
2588
2627
  }
2589
2628
  if (confirmedFiles.length > 0 && syncState.socket) await sendMessage(syncState.socket, {
2590
2629
  type: "file-delete",
@@ -2603,7 +2642,7 @@ async function executeEffect(effect, context) {
2603
2642
  if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
2604
2643
  if (wasDisconnected) {
2605
2644
  if (didShowDisconnect()) {
2606
- 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)`);
2607
2646
  status("Watching for changes...");
2608
2647
  }
2609
2648
  resetDisconnectState();
@@ -2613,9 +2652,10 @@ async function executeEffect(effect, context) {
2613
2652
  const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
2614
2653
  if (effect.totalCount === 0 && relativeDirectory) if (config.projectDirCreated) success(`Created ${relativeDirectory} folder`);
2615
2654
  else success(`Syncing to ${relativeDirectory} folder`);
2616
- else if (relativeDirectory && config.projectDirCreated) success(`Synced into ${relativeDirectory} (${effect.updatedCount} files added)`);
2617
- else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${effect.updatedCount} files updated, ${effect.unchangedCount} unchanged)`);
2618
- 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)`);
2658
+ if (config.projectDirCreated && config.projectDir) tryGitInit(config.projectDir);
2619
2659
  status("Watching for changes...");
2620
2660
  return [];
2621
2661
  }
@@ -2677,6 +2717,14 @@ async function start(config) {
2677
2717
  }
2678
2718
  (async () => {
2679
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
+ }
2680
2728
  if (!wasRecentlyDisconnected() && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
2681
2729
  await processEvent({
2682
2730
  type: "HANDSHAKE",
@@ -2707,7 +2755,7 @@ async function start(config) {
2707
2755
  event = { type: "REQUEST_FILES" };
2708
2756
  break;
2709
2757
  case "file-list":
2710
- debug(`Received file list: ${message.files.length} files`);
2758
+ debug(`Received file list: ${pluralize(message.files.length, "file")}`);
2711
2759
  event = {
2712
2760
  type: "REMOTE_FILE_LIST",
2713
2761
  files: message.files
@@ -2783,7 +2831,11 @@ async function start(config) {
2783
2831
  }
2784
2832
  })();
2785
2833
  });
2786
- connection.on("disconnect", () => {
2834
+ connection.on("disconnect", (client) => {
2835
+ if (syncState.socket !== client) {
2836
+ debug("[STATE] Ignoring disconnect from stale socket");
2837
+ return;
2838
+ }
2787
2839
  scheduleDisconnectMessage(() => {
2788
2840
  status("Disconnected, waiting to reconnect...");
2789
2841
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.14.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",