framer-code-link 0.17.0 → 0.18.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.
- package/dist/index.mjs +608 -120
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -3,10 +3,13 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import fs from "fs/promises";
|
|
5
5
|
import path from "path";
|
|
6
|
-
import { WebSocketServer } from "ws";
|
|
7
6
|
import { createHash } from "crypto";
|
|
8
|
-
import { execSync } from "child_process";
|
|
9
|
-
import
|
|
7
|
+
import { execFile, execSync } from "child_process";
|
|
8
|
+
import nodeFs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import { promisify } from "util";
|
|
11
|
+
import https from "node:https";
|
|
12
|
+
import { WebSocketServer } from "ws";
|
|
10
13
|
import { setupTypeAcquisition } from "@typescript/ata";
|
|
11
14
|
import ts from "typescript";
|
|
12
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -142,7 +145,7 @@ function pathJoin(...parts) {
|
|
|
142
145
|
});
|
|
143
146
|
return res;
|
|
144
147
|
}
|
|
145
|
-
function normalizePath
|
|
148
|
+
function normalizePath(filePath) {
|
|
146
149
|
if (!filePath) return "";
|
|
147
150
|
const isAbsolute = filePath.startsWith("/");
|
|
148
151
|
const segments = filePath.replace(/\\/g, "/").split("/");
|
|
@@ -159,12 +162,24 @@ function normalizePath$1(filePath) {
|
|
|
159
162
|
if (isAbsolute) return `/${normalized}`;
|
|
160
163
|
return normalized;
|
|
161
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Use when you only want path normalization.
|
|
167
|
+
* Preserves the caller-provided extension so `Foo.ts` and `Foo.tsx` stay distinct.
|
|
168
|
+
*/
|
|
162
169
|
function normalizeCodeFilePath(filePath) {
|
|
163
|
-
const normalized = normalizePath
|
|
170
|
+
const normalized = normalizePath(filePath);
|
|
164
171
|
return normalized.startsWith("/") ? normalized.slice(1) : normalized;
|
|
165
172
|
}
|
|
166
|
-
function
|
|
167
|
-
|
|
173
|
+
function ensureExtension(filePath, extension = ".tsx") {
|
|
174
|
+
const normalized = normalizeCodeFilePath(filePath);
|
|
175
|
+
return /\.(tsx?|jsx?|json)$/i.test(normalized) ? normalized : `${normalized}${extension}`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Use when the path must match the code-file API contract.
|
|
179
|
+
* Normalizes the path and ensures a default `.tsx` extension when one is missing.
|
|
180
|
+
*/
|
|
181
|
+
function normalizeCodeFilePathWithExtension(filePath) {
|
|
182
|
+
return ensureExtension(filePath);
|
|
168
183
|
}
|
|
169
184
|
function sanitizeFilePath(input, capitalizeReactComponent = true) {
|
|
170
185
|
const trimmed = input.trim();
|
|
@@ -188,7 +203,7 @@ function isSupportedExtension$1(filePath) {
|
|
|
188
203
|
* Use this for Map keys on operating systems where "File.tsx" and "file.tsx" are the same file.
|
|
189
204
|
*/
|
|
190
205
|
function fileKeyForLookup(filePath) {
|
|
191
|
-
return
|
|
206
|
+
return normalizeCodeFilePath(filePath).toLowerCase();
|
|
192
207
|
}
|
|
193
208
|
/**
|
|
194
209
|
* Pluralize a word based on count
|
|
@@ -220,6 +235,11 @@ function getPortFromHash(projectHash) {
|
|
|
220
235
|
return 3847 + Math.abs(hash) % 250;
|
|
221
236
|
}
|
|
222
237
|
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region ../code-link-shared/src/types.ts
|
|
240
|
+
/** Custom close code sent when a new plugin tab replaces the active one. */
|
|
241
|
+
const CLOSE_CODE_REPLACED = 4001;
|
|
242
|
+
|
|
223
243
|
//#endregion
|
|
224
244
|
//#region ../../node_modules/picocolors/picocolors.js
|
|
225
245
|
var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
@@ -475,28 +495,249 @@ function resetDisconnectState() {
|
|
|
475
495
|
hadRecentDisconnect = false;
|
|
476
496
|
}
|
|
477
497
|
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/helpers/certs.ts
|
|
500
|
+
/**
|
|
501
|
+
* Certificate management for WSS support.
|
|
502
|
+
*
|
|
503
|
+
* Downloads FiloSottile's mkcert binary on first run, then shells out to it
|
|
504
|
+
* to generate and trust a local CA + server certificate for wss://localhost.
|
|
505
|
+
*
|
|
506
|
+
* The mkcert binary is SHA-256 verified before execution (update
|
|
507
|
+
* MKCERT_CHECKSUMS when bumping MKCERT_VERSION). The CA key is user-only;
|
|
508
|
+
* never share or commit the cert directory.
|
|
509
|
+
*
|
|
510
|
+
* Certs and the mkcert binary are cached in ~/.framer/code-link/.
|
|
511
|
+
*/
|
|
512
|
+
const execFileAsync = promisify(execFile);
|
|
513
|
+
/** Keep in sync with MKCERT_CHECKSUMS below. */
|
|
514
|
+
const MKCERT_VERSION = "v1.4.4";
|
|
515
|
+
const CERT_DIR = process.env.FRAMER_CODE_LINK_CERT_DIR ?? path.join(os.homedir(), ".framer", "code-link");
|
|
516
|
+
const MKCERT_BIN_NAME = process.platform === "win32" ? "mkcert.exe" : "mkcert";
|
|
517
|
+
const MKCERT_BIN_PATH = path.join(CERT_DIR, MKCERT_BIN_NAME);
|
|
518
|
+
const ROOT_CA_CERT_PATH = path.join(CERT_DIR, "rootCA.pem");
|
|
519
|
+
const ROOT_CA_KEY_PATH = path.join(CERT_DIR, "rootCA-key.pem");
|
|
520
|
+
const SERVER_KEY_PATH = path.join(CERT_DIR, "localhost-key.pem");
|
|
521
|
+
const SERVER_CERT_PATH = path.join(CERT_DIR, "localhost.pem");
|
|
522
|
+
/**
|
|
523
|
+
* SHA-256 checksums for mkcert v1.4.4 release binaries, keyed by "platform-arch".
|
|
524
|
+
* These must be updated whenever MKCERT_VERSION changes.
|
|
525
|
+
* Source: https://github.com/FiloSottile/mkcert/releases/tag/v1.4.4
|
|
526
|
+
*/
|
|
527
|
+
const MKCERT_CHECKSUMS = {
|
|
528
|
+
"darwin-amd64": "a32dfab51f1845d51e810db8e47dcf0e6b51ae3422426514bf5a2b8302e97d4e",
|
|
529
|
+
"darwin-arm64": "c8af0df44bce04359794dad8ea28d750437411d632748049d08644ffb66a60c6",
|
|
530
|
+
"linux-amd64": "6d31c65b03972c6dc4a14ab429f2928300518b26503f58723e532d1b0a3bbb52",
|
|
531
|
+
"linux-arm64": "b98f2cc69fd9147fe4d405d859c57504571adec0d3611c3eefd04107c7ac00d0",
|
|
532
|
+
"windows-amd64": "d2660b50a9ed59eada480750561c96abc2ed4c9a38c6a24d93e30e0977631398",
|
|
533
|
+
"windows-arm64": "793747256c562622d40127c8080df26add2fb44c50906ce9db63b42a5280582e"
|
|
534
|
+
};
|
|
535
|
+
/** Env vars passed to every mkcert invocation. */
|
|
536
|
+
const MKCERT_ENV = {
|
|
537
|
+
...process.env,
|
|
538
|
+
CAROOT: CERT_DIR,
|
|
539
|
+
JAVA_HOME: "",
|
|
540
|
+
...process.platform === "darwin" ? { TRUST_STORES: "system" } : {}
|
|
541
|
+
};
|
|
542
|
+
/**
|
|
543
|
+
* Returns a TLS cert bundle for the WSS server, or null if generation fails.
|
|
544
|
+
* On first run, downloads mkcert, installs a local CA into trust stores, and
|
|
545
|
+
* generates a server cert for localhost.
|
|
546
|
+
*/
|
|
547
|
+
async function getOrCreateCerts() {
|
|
548
|
+
try {
|
|
549
|
+
await fs.mkdir(CERT_DIR, { recursive: true });
|
|
550
|
+
const mkcertPath = await ensureMkcertBinary();
|
|
551
|
+
const rootCAState = await syncRootCA(mkcertPath);
|
|
552
|
+
if (rootCAState !== "unchanged") await invalidateServerCerts(rootCAState);
|
|
553
|
+
const existingKey = await loadFile(SERVER_KEY_PATH);
|
|
554
|
+
const existingCert = await loadFile(SERVER_CERT_PATH);
|
|
555
|
+
if (existingKey && existingCert) {
|
|
556
|
+
debug("Loaded existing server certificates from disk");
|
|
557
|
+
return {
|
|
558
|
+
key: existingKey,
|
|
559
|
+
cert: existingCert
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
if (existingKey || existingCert) await invalidateIncompleteServerBundle();
|
|
563
|
+
status("Generating local certificates to connect securely. You may be asked for your password.");
|
|
564
|
+
await generateCerts(mkcertPath);
|
|
565
|
+
return {
|
|
566
|
+
key: await fs.readFile(SERVER_KEY_PATH, "utf-8"),
|
|
567
|
+
cert: await fs.readFile(SERVER_CERT_PATH, "utf-8")
|
|
568
|
+
};
|
|
569
|
+
} catch (err) {
|
|
570
|
+
error(`Failed to set up TLS certificates: ${err instanceof Error ? err.message : String(err)}`);
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function getDownloadInfo() {
|
|
575
|
+
const platformMap = {
|
|
576
|
+
darwin: "darwin",
|
|
577
|
+
linux: "linux",
|
|
578
|
+
win32: "windows"
|
|
579
|
+
};
|
|
580
|
+
const archMap = {
|
|
581
|
+
x64: "amd64",
|
|
582
|
+
arm64: "arm64"
|
|
583
|
+
};
|
|
584
|
+
const platform = platformMap[process.platform];
|
|
585
|
+
const arch = archMap[process.arch];
|
|
586
|
+
if (!platform || !arch) throw new Error(`Unsupported platform: ${process.platform}/${process.arch}. Install mkcert manually: https://github.com/FiloSottile/mkcert#installation`);
|
|
587
|
+
const key = `${platform}-${arch}`;
|
|
588
|
+
const expectedChecksum = MKCERT_CHECKSUMS[key];
|
|
589
|
+
if (!expectedChecksum) throw new Error(`No checksum available for mkcert ${key}. Install mkcert manually: https://github.com/FiloSottile/mkcert#installation`);
|
|
590
|
+
return {
|
|
591
|
+
url: `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}/${`mkcert-${MKCERT_VERSION}-${platform}-${arch}${process.platform === "win32" ? ".exe" : ""}`}`,
|
|
592
|
+
expectedChecksum
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
async function ensureMkcertBinary() {
|
|
596
|
+
const { url, expectedChecksum } = getDownloadInfo();
|
|
597
|
+
try {
|
|
598
|
+
await fs.access(MKCERT_BIN_PATH, nodeFs.constants.X_OK);
|
|
599
|
+
if (await verifyFileChecksum(MKCERT_BIN_PATH, expectedChecksum)) {
|
|
600
|
+
debug("mkcert binary already available and verified");
|
|
601
|
+
return MKCERT_BIN_PATH;
|
|
602
|
+
}
|
|
603
|
+
warn("Cached mkcert binary failed checksum verification, re-downloading...");
|
|
604
|
+
} catch {}
|
|
605
|
+
debug(`Downloading mkcert from ${url}`);
|
|
606
|
+
status("Downloading mkcert for certificate generation...");
|
|
607
|
+
try {
|
|
608
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
609
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
610
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
611
|
+
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
612
|
+
if (actualChecksum !== expectedChecksum) throw new Error(`mkcert binary checksum mismatch — the download may have been tampered with.\n Expected: ${expectedChecksum}\n Actual: ${actualChecksum}`);
|
|
613
|
+
await fs.writeFile(MKCERT_BIN_PATH, buffer, { mode: 493 });
|
|
614
|
+
debug(`mkcert binary saved to ${MKCERT_BIN_PATH}`);
|
|
615
|
+
return MKCERT_BIN_PATH;
|
|
616
|
+
} catch (err) {
|
|
617
|
+
await fs.rm(MKCERT_BIN_PATH, { force: true });
|
|
618
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
619
|
+
throw new Error(`Failed to download mkcert: ${message}\nYou can install it manually: https://github.com/FiloSottile/mkcert#installation\nThen run: mkcert -install && mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async function generateCerts(mkcertPath) {
|
|
623
|
+
debug("Running mkcert to install the local root CA...");
|
|
624
|
+
try {
|
|
625
|
+
await execFileAsync(mkcertPath, ["-install"], { env: MKCERT_ENV });
|
|
626
|
+
} catch (err) {
|
|
627
|
+
throw new Error(`Failed to install mkcert root CA into the system trust store. If you canceled the password prompt, rerun this command and allow the install.
|
|
628
|
+
mkcert error: ${formatMkcertError(err)}`);
|
|
629
|
+
}
|
|
630
|
+
debug("Running mkcert to generate the localhost server certificate...");
|
|
631
|
+
try {
|
|
632
|
+
await execFileAsync(mkcertPath, [
|
|
633
|
+
"-key-file",
|
|
634
|
+
SERVER_KEY_PATH,
|
|
635
|
+
"-cert-file",
|
|
636
|
+
SERVER_CERT_PATH,
|
|
637
|
+
"localhost",
|
|
638
|
+
"127.0.0.1"
|
|
639
|
+
], { env: MKCERT_ENV });
|
|
640
|
+
} catch (err) {
|
|
641
|
+
if (await loadFile(SERVER_KEY_PATH) || await loadFile(SERVER_CERT_PATH)) await invalidateIncompleteServerBundle();
|
|
642
|
+
throw new Error(`Failed to generate localhost TLS certificate and key with mkcert.
|
|
643
|
+
mkcert error: ${formatMkcertError(err)}\nPlease rerun:\n mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1`);
|
|
644
|
+
}
|
|
645
|
+
const [generatedKey, generatedCert] = await Promise.all([loadFile(SERVER_KEY_PATH), loadFile(SERVER_CERT_PATH)]);
|
|
646
|
+
if (generatedKey && generatedCert) {
|
|
647
|
+
debug("CA installed and server certificate generated successfully");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (generatedKey || generatedCert) await invalidateIncompleteServerBundle();
|
|
651
|
+
throw new Error(`Failed to generate localhost TLS certificate and key with mkcert. Please ensure mkcert is installed and rerun:
|
|
652
|
+
mkcert -install && mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1`);
|
|
653
|
+
}
|
|
654
|
+
async function syncRootCA(mkcertPath) {
|
|
655
|
+
const existingRootCert = await loadFile(ROOT_CA_CERT_PATH);
|
|
656
|
+
const existingRootKey = await loadFile(ROOT_CA_KEY_PATH);
|
|
657
|
+
const { stdout } = await execFileAsync(mkcertPath, ["-CAROOT"], { env: {
|
|
658
|
+
...process.env,
|
|
659
|
+
JAVA_HOME: ""
|
|
660
|
+
} });
|
|
661
|
+
const defaultCAROOT = stdout.trim();
|
|
662
|
+
if (!defaultCAROOT || defaultCAROOT === CERT_DIR) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
663
|
+
const defaultRootCert = await loadFile(path.join(defaultCAROOT, "rootCA.pem"));
|
|
664
|
+
const defaultRootKey = await loadFile(path.join(defaultCAROOT, "rootCA-key.pem"));
|
|
665
|
+
if (!defaultRootCert || !defaultRootKey) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
666
|
+
if (existingRootCert === defaultRootCert && existingRootKey === defaultRootKey) return "unchanged";
|
|
667
|
+
await Promise.all([fs.rm(ROOT_CA_CERT_PATH, { force: true }), fs.rm(ROOT_CA_KEY_PATH, { force: true })]);
|
|
668
|
+
await fs.writeFile(ROOT_CA_CERT_PATH, defaultRootCert, { mode: 420 });
|
|
669
|
+
await fs.writeFile(ROOT_CA_KEY_PATH, defaultRootKey, { mode: 384 });
|
|
670
|
+
return existingRootCert && existingRootKey ? "updated" : "copied";
|
|
671
|
+
}
|
|
672
|
+
async function invalidateServerCerts(rootCAState) {
|
|
673
|
+
const reasons = {
|
|
674
|
+
copied: "Copied an existing mkcert root CA into the Code Link cache",
|
|
675
|
+
updated: "Detected a different mkcert root CA and refreshed the Code Link cache",
|
|
676
|
+
missing: "No cached mkcert root CA was available for the existing server certificate"
|
|
677
|
+
};
|
|
678
|
+
if (!(await loadFile(SERVER_KEY_PATH) !== null || await loadFile(SERVER_CERT_PATH) !== null)) return;
|
|
679
|
+
await fs.rm(SERVER_KEY_PATH, { force: true });
|
|
680
|
+
await fs.rm(SERVER_CERT_PATH, { force: true });
|
|
681
|
+
debug(`${reasons[rootCAState]}; removed stale localhost certificate`);
|
|
682
|
+
}
|
|
683
|
+
async function invalidateIncompleteServerBundle() {
|
|
684
|
+
await fs.rm(SERVER_KEY_PATH, { force: true });
|
|
685
|
+
await fs.rm(SERVER_CERT_PATH, { force: true });
|
|
686
|
+
warn("Found an incomplete localhost certificate bundle; regenerating it");
|
|
687
|
+
}
|
|
688
|
+
async function verifyFileChecksum(filePath, expectedHash) {
|
|
689
|
+
const data = await fs.readFile(filePath);
|
|
690
|
+
return createHash("sha256").update(data).digest("hex") === expectedHash;
|
|
691
|
+
}
|
|
692
|
+
async function loadFile(filePath) {
|
|
693
|
+
try {
|
|
694
|
+
return await fs.readFile(filePath, "utf-8");
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function formatMkcertError(err) {
|
|
700
|
+
if (err instanceof Error) {
|
|
701
|
+
const stdout = "stdout" in err && typeof err.stdout === "string" ? err.stdout.trim() : "";
|
|
702
|
+
const output = ["stderr" in err && typeof err.stderr === "string" ? err.stderr.trim() : "", stdout].filter(Boolean).join("\n");
|
|
703
|
+
return output ? `${err.message}\n${output}` : err.message;
|
|
704
|
+
}
|
|
705
|
+
return String(err);
|
|
706
|
+
}
|
|
707
|
+
|
|
478
708
|
//#endregion
|
|
479
709
|
//#region src/helpers/connection.ts
|
|
480
710
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
711
|
+
* WebSocket connection helper
|
|
712
|
+
*
|
|
713
|
+
* Wrapper around ws.Server that normalizes handshake and surfaces callbacks.
|
|
714
|
+
*/
|
|
715
|
+
/**
|
|
716
|
+
* Initializes a WSS (TLS) WebSocket server and returns a connection interface.
|
|
717
|
+
* Returns a Promise that resolves when the server is ready, or rejects on startup errors.
|
|
483
718
|
*/
|
|
484
|
-
function initConnection(port) {
|
|
719
|
+
function initConnection(port, certs) {
|
|
485
720
|
return new Promise((resolve, reject) => {
|
|
486
|
-
const wss = new WebSocketServer({ port });
|
|
487
721
|
const handlers = {};
|
|
488
722
|
let connectionId = 0;
|
|
489
723
|
let isReady = false;
|
|
724
|
+
const httpsServer = https.createServer({
|
|
725
|
+
key: certs.key,
|
|
726
|
+
cert: certs.cert
|
|
727
|
+
});
|
|
728
|
+
const wss = new WebSocketServer({ server: httpsServer });
|
|
490
729
|
wss.on("error", (err) => {
|
|
730
|
+
error(`WebSocket server error: ${err.message}`);
|
|
731
|
+
handlers.onError?.(err);
|
|
732
|
+
});
|
|
733
|
+
const handleError = (err) => {
|
|
491
734
|
if (!isReady) {
|
|
492
735
|
if (err.code === "EADDRINUSE") {
|
|
493
736
|
error(`Port ${port} is already in use.`);
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
error(` 2. Or run: lsof -i :${port} | grep LISTEN`);
|
|
499
|
-
error(` Then kill the process: kill -9 <PID>`);
|
|
737
|
+
info(`This usually means another instance of Code Link is already running.`);
|
|
738
|
+
info(``);
|
|
739
|
+
info(`To fix this:`);
|
|
740
|
+
info(` Close any other terminal running Code Link for this project`);
|
|
500
741
|
reject(/* @__PURE__ */ new Error(`Port ${port} is already in use`));
|
|
501
742
|
} else {
|
|
502
743
|
error(`Failed to start WebSocket server: ${err.message}`);
|
|
@@ -505,10 +746,11 @@ function initConnection(port) {
|
|
|
505
746
|
return;
|
|
506
747
|
}
|
|
507
748
|
error(`WebSocket server error: ${err.message}`);
|
|
508
|
-
|
|
509
|
-
|
|
749
|
+
handlers.onError?.(err);
|
|
750
|
+
};
|
|
751
|
+
const handleListening = () => {
|
|
510
752
|
isReady = true;
|
|
511
|
-
debug(`
|
|
753
|
+
debug(`WSS server listening on port ${port}`);
|
|
512
754
|
let activeClient = null;
|
|
513
755
|
wss.on("connection", (ws) => {
|
|
514
756
|
const connId = ++connectionId;
|
|
@@ -524,7 +766,7 @@ function initConnection(port) {
|
|
|
524
766
|
activeClient = ws;
|
|
525
767
|
if (previousActiveClient && previousActiveClient !== activeClient) {
|
|
526
768
|
debug(`Replacing active client with conn ${connId}`);
|
|
527
|
-
if (previousActiveClient.readyState === READY_STATE.OPEN || previousActiveClient.readyState === READY_STATE.CONNECTING) previousActiveClient.close();
|
|
769
|
+
if (previousActiveClient.readyState === READY_STATE.OPEN || previousActiveClient.readyState === READY_STATE.CONNECTING) previousActiveClient.close(CLOSE_CODE_REPLACED);
|
|
528
770
|
}
|
|
529
771
|
handlers.onHandshake?.(ws, message);
|
|
530
772
|
} else if (handshakeReceived && activeClient === ws) handlers.onMessage?.(message);
|
|
@@ -564,9 +806,13 @@ function initConnection(port) {
|
|
|
564
806
|
},
|
|
565
807
|
close() {
|
|
566
808
|
wss.close();
|
|
809
|
+
httpsServer.close();
|
|
567
810
|
}
|
|
568
811
|
});
|
|
569
|
-
}
|
|
812
|
+
};
|
|
813
|
+
httpsServer.on("error", handleError);
|
|
814
|
+
httpsServer.on("listening", handleListening);
|
|
815
|
+
httpsServer.listen(port);
|
|
570
816
|
});
|
|
571
817
|
}
|
|
572
818
|
/**
|
|
@@ -608,41 +854,6 @@ function sendMessage(socket, message) {
|
|
|
608
854
|
});
|
|
609
855
|
}
|
|
610
856
|
|
|
611
|
-
//#endregion
|
|
612
|
-
//#region src/utils/node-paths.ts
|
|
613
|
-
/**
|
|
614
|
-
* Path manipulation utilities
|
|
615
|
-
*/
|
|
616
|
-
/**
|
|
617
|
-
* Gets a relative path from the project directory
|
|
618
|
-
*/
|
|
619
|
-
function getRelativePath(projectDir, absolutePath) {
|
|
620
|
-
return path.relative(projectDir, absolutePath);
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* Normalizes a file path by:
|
|
624
|
-
* - Converting backslashes to forward slashes
|
|
625
|
-
* - Resolving . and .. segments
|
|
626
|
-
* - Removing duplicate slashes
|
|
627
|
-
*/
|
|
628
|
-
function normalizePath(filePath) {
|
|
629
|
-
if (!filePath) return "";
|
|
630
|
-
const isAbsolute = filePath.startsWith("/");
|
|
631
|
-
const segments = filePath.replace(/\\/g, "/").split("/");
|
|
632
|
-
const stack = [];
|
|
633
|
-
for (const segment of segments) {
|
|
634
|
-
if (!segment || segment === ".") continue;
|
|
635
|
-
if (segment === "..") {
|
|
636
|
-
if (stack.length > 0) stack.pop();
|
|
637
|
-
continue;
|
|
638
|
-
}
|
|
639
|
-
stack.push(segment);
|
|
640
|
-
}
|
|
641
|
-
const normalized = stack.join("/");
|
|
642
|
-
if (isAbsolute) return `/${normalized}`;
|
|
643
|
-
return normalized;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
857
|
//#endregion
|
|
647
858
|
//#region src/utils/state-persistence.ts
|
|
648
859
|
/**
|
|
@@ -654,18 +865,8 @@ function normalizePath(filePath) {
|
|
|
654
865
|
*/
|
|
655
866
|
const STATE_FILE_NAME = ".framer-sync-state.json";
|
|
656
867
|
const CURRENT_VERSION = 1;
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
".tsx",
|
|
660
|
-
".js",
|
|
661
|
-
".jsx",
|
|
662
|
-
".json"
|
|
663
|
-
];
|
|
664
|
-
const DEFAULT_EXTENSION$1 = ".tsx";
|
|
665
|
-
function normalizePersistedFileName(fileName) {
|
|
666
|
-
let normalized = normalizePath(fileName.trim());
|
|
667
|
-
if (!SUPPORTED_EXTENSIONS$1.some((ext) => normalized.toLowerCase().endsWith(ext))) normalized = `${normalized}${DEFAULT_EXTENSION$1}`;
|
|
668
|
-
return normalized;
|
|
868
|
+
function persistedFileKey(fileName) {
|
|
869
|
+
return fileKeyForLookup(normalizeCodeFilePathWithExtension(fileName));
|
|
669
870
|
}
|
|
670
871
|
/**
|
|
671
872
|
* Hash file content to detect changes
|
|
@@ -687,7 +888,7 @@ async function loadPersistedState(projectDir) {
|
|
|
687
888
|
return result;
|
|
688
889
|
}
|
|
689
890
|
for (const [fileName, state] of Object.entries(parsed.files)) {
|
|
690
|
-
const normalizedName =
|
|
891
|
+
const normalizedName = persistedFileKey(fileName);
|
|
691
892
|
if (normalizedName !== fileName) debug(`Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility`);
|
|
692
893
|
result.set(normalizedName, state);
|
|
693
894
|
}
|
|
@@ -755,7 +956,7 @@ async function listFiles(filesDir) {
|
|
|
755
956
|
continue;
|
|
756
957
|
}
|
|
757
958
|
if (!isSupportedExtension(entry.name)) continue;
|
|
758
|
-
const sanitizedPath = sanitizeFilePath(normalizePath
|
|
959
|
+
const sanitizedPath = sanitizeFilePath(normalizePath(path.relative(filesDir, entryPath)), false).path;
|
|
759
960
|
try {
|
|
760
961
|
const [content, stats] = await Promise.all([fs.readFile(entryPath, "utf-8"), fs.stat(entryPath)]);
|
|
761
962
|
files.push({
|
|
@@ -1000,9 +1201,9 @@ function resolveRemoteReference(filesDir, rawName) {
|
|
|
1000
1201
|
};
|
|
1001
1202
|
}
|
|
1002
1203
|
function sanitizeRelativePath(relativePath) {
|
|
1003
|
-
const trimmed = normalizePath
|
|
1204
|
+
const trimmed = normalizePath(relativePath.trim());
|
|
1004
1205
|
const sanitized = sanitizeFilePath(SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext)) ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`, false);
|
|
1005
|
-
const normalized = normalizePath
|
|
1206
|
+
const normalized = normalizePath(sanitized.path);
|
|
1006
1207
|
return {
|
|
1007
1208
|
relativePath: normalized,
|
|
1008
1209
|
extension: sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION
|
|
@@ -1061,6 +1262,7 @@ function tryGitInit(projectDir) {
|
|
|
1061
1262
|
debug("Already in a repository, skipping git init");
|
|
1062
1263
|
return false;
|
|
1063
1264
|
}
|
|
1265
|
+
status("Initializing git repository...");
|
|
1064
1266
|
execSync("git init", {
|
|
1065
1267
|
stdio: "ignore",
|
|
1066
1268
|
cwd: projectDir
|
|
@@ -1082,7 +1284,7 @@ function tryGitInit(projectDir) {
|
|
|
1082
1284
|
return true;
|
|
1083
1285
|
} catch (e) {
|
|
1084
1286
|
if (didInit) try {
|
|
1085
|
-
|
|
1287
|
+
nodeFs.rmSync(path.join(projectDir, ".git"), {
|
|
1086
1288
|
recursive: true,
|
|
1087
1289
|
force: true
|
|
1088
1290
|
});
|
|
@@ -1379,7 +1581,8 @@ var Installer = class {
|
|
|
1379
1581
|
try {
|
|
1380
1582
|
await this.ata(filteredContent);
|
|
1381
1583
|
} catch (err) {
|
|
1382
|
-
warn(`
|
|
1584
|
+
warn(`Type fetching failed for ${fileName}`);
|
|
1585
|
+
debug(`ATA error for ${fileName}:`, err);
|
|
1383
1586
|
}
|
|
1384
1587
|
}
|
|
1385
1588
|
/**
|
|
@@ -1502,7 +1705,7 @@ declare module "*.json"
|
|
|
1502
1705
|
private: true,
|
|
1503
1706
|
description: "Framer files synced with framer-code-link"
|
|
1504
1707
|
};
|
|
1505
|
-
await fs.writeFile(packagePath, JSON.stringify(pkg, null,
|
|
1708
|
+
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 4));
|
|
1506
1709
|
debug("Created package.json");
|
|
1507
1710
|
}
|
|
1508
1711
|
}
|
|
@@ -1634,11 +1837,12 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
|
|
|
1634
1837
|
if (isRetryable) checkFatalFailure(urlString);
|
|
1635
1838
|
if (attempt < retries && isRetryable) {
|
|
1636
1839
|
const delay = attempt * 1e3;
|
|
1637
|
-
|
|
1840
|
+
debug(`Fetch failed for ${urlString}, retrying...`, error);
|
|
1638
1841
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1639
1842
|
continue;
|
|
1640
1843
|
}
|
|
1641
|
-
warn(`Fetch failed for ${urlString}
|
|
1844
|
+
warn(`Fetch failed for ${urlString}`);
|
|
1845
|
+
debug(`Fetch error details:`, error);
|
|
1642
1846
|
throw error;
|
|
1643
1847
|
}
|
|
1644
1848
|
}
|
|
@@ -1783,6 +1987,18 @@ function validateIncomingChange(fileMeta, currentMode) {
|
|
|
1783
1987
|
};
|
|
1784
1988
|
}
|
|
1785
1989
|
|
|
1990
|
+
//#endregion
|
|
1991
|
+
//#region src/utils/node-paths.ts
|
|
1992
|
+
/**
|
|
1993
|
+
* Path manipulation utilities
|
|
1994
|
+
*/
|
|
1995
|
+
/**
|
|
1996
|
+
* Gets a relative path from the project directory
|
|
1997
|
+
*/
|
|
1998
|
+
function getRelativePath(projectDir, absolutePath) {
|
|
1999
|
+
return path.relative(projectDir, absolutePath);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1786
2002
|
//#endregion
|
|
1787
2003
|
//#region src/helpers/watcher.ts
|
|
1788
2004
|
/**
|
|
@@ -1790,47 +2006,244 @@ function validateIncomingChange(fileMeta, currentMode) {
|
|
|
1790
2006
|
*
|
|
1791
2007
|
* Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json.
|
|
1792
2008
|
*/
|
|
2009
|
+
const RENAME_BUFFER_MS = 100;
|
|
2010
|
+
function findUniqueHashMatch(pendingItems, contentHash) {
|
|
2011
|
+
let matchingKey;
|
|
2012
|
+
for (const [key, pending] of pendingItems) {
|
|
2013
|
+
if (pending.contentHash !== contentHash) continue;
|
|
2014
|
+
if (matchingKey !== void 0) return;
|
|
2015
|
+
matchingKey = key;
|
|
2016
|
+
}
|
|
2017
|
+
return matchingKey;
|
|
2018
|
+
}
|
|
2019
|
+
function matchPendingAddForDelete(contentHash, pendingAdds) {
|
|
2020
|
+
if (!contentHash) return null;
|
|
2021
|
+
const matchingAddKey = findUniqueHashMatch(pendingAdds, contentHash);
|
|
2022
|
+
if (!matchingAddKey) return null;
|
|
2023
|
+
const pendingAdd = pendingAdds.get(matchingAddKey);
|
|
2024
|
+
if (!pendingAdd) return null;
|
|
2025
|
+
return {
|
|
2026
|
+
key: matchingAddKey,
|
|
2027
|
+
pendingAdd
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
function matchPendingDeleteForAdd(contentHash, pendingDeletes) {
|
|
2031
|
+
const matchingDeleteKey = findUniqueHashMatch(pendingDeletes, contentHash);
|
|
2032
|
+
if (!matchingDeleteKey) return null;
|
|
2033
|
+
const pendingDelete = pendingDeletes.get(matchingDeleteKey);
|
|
2034
|
+
if (!pendingDelete) return null;
|
|
2035
|
+
return {
|
|
2036
|
+
key: matchingDeleteKey,
|
|
2037
|
+
pendingDelete
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
1793
2040
|
/**
|
|
1794
2041
|
* Initializes a file watcher for the given directory
|
|
1795
2042
|
*/
|
|
1796
2043
|
function initWatcher(filesDir) {
|
|
1797
2044
|
const handlers = [];
|
|
2045
|
+
const contentHashCache = /* @__PURE__ */ new Map();
|
|
2046
|
+
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
2047
|
+
const pendingAdds = /* @__PURE__ */ new Map();
|
|
2048
|
+
const recentSanitizations = /* @__PURE__ */ new Set();
|
|
1798
2049
|
const watcher = chokidar.watch(filesDir, {
|
|
1799
2050
|
ignored: /(^|[/\\])\.\./,
|
|
1800
2051
|
persistent: true,
|
|
1801
2052
|
ignoreInitial: false
|
|
1802
2053
|
});
|
|
1803
2054
|
debug(`Watching directory: ${filesDir}`);
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2055
|
+
const dispatchEvent = (event) => {
|
|
2056
|
+
let eventToDispatch = event;
|
|
2057
|
+
if (event.kind === "rename" && event.relativePath === event.oldRelativePath) {
|
|
2058
|
+
if (event.content === void 0) {
|
|
2059
|
+
warn(`Skipping invalid same-path rename without content: ${event.relativePath}`);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
debug(`Converting same-path rename to change: ${event.relativePath}`);
|
|
2063
|
+
eventToDispatch = {
|
|
2064
|
+
kind: "change",
|
|
2065
|
+
relativePath: event.relativePath,
|
|
2066
|
+
content: event.content
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
debug(`Watcher event: ${eventToDispatch.kind} ${eventToDispatch.relativePath}`);
|
|
2070
|
+
for (const handler of handlers) handler(eventToDispatch);
|
|
2071
|
+
};
|
|
2072
|
+
/**
|
|
2073
|
+
* Resolves the relative path identity for a watcher event.
|
|
2074
|
+
* Only "add" may rewrite that identity by successfully sanitizing on disk.
|
|
2075
|
+
*/
|
|
2076
|
+
const resolveRelativePath = async (kind, absolutePath) => {
|
|
2077
|
+
if (!isSupportedExtension$1(absolutePath)) return null;
|
|
2078
|
+
const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
|
|
2079
|
+
let relativePath = rawRelativePath;
|
|
1808
2080
|
let effectiveAbsolutePath = absolutePath;
|
|
1809
|
-
if (
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
2081
|
+
if (kind === "add") {
|
|
2082
|
+
const sanitized = sanitizeFilePath(rawRelativePath, false);
|
|
2083
|
+
if (sanitized.path !== rawRelativePath) {
|
|
2084
|
+
const nextRelativePath = sanitized.path;
|
|
2085
|
+
const newAbsolutePath = path.join(filesDir, nextRelativePath);
|
|
2086
|
+
try {
|
|
2087
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
|
|
2088
|
+
await fs.rename(absolutePath, newAbsolutePath);
|
|
2089
|
+
debug(`Renamed ${rawRelativePath} -> ${nextRelativePath}`);
|
|
2090
|
+
relativePath = nextRelativePath;
|
|
2091
|
+
effectiveAbsolutePath = newAbsolutePath;
|
|
2092
|
+
recentSanitizations.add(rawRelativePath);
|
|
2093
|
+
recentSanitizations.add(nextRelativePath);
|
|
2094
|
+
setTimeout(() => {
|
|
2095
|
+
recentSanitizations.delete(rawRelativePath);
|
|
2096
|
+
recentSanitizations.delete(nextRelativePath);
|
|
2097
|
+
}, RENAME_BUFFER_MS * 3);
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
warn(`Failed to rename ${rawRelativePath}`, err);
|
|
2100
|
+
return {
|
|
2101
|
+
relativePath: rawRelativePath,
|
|
2102
|
+
effectiveAbsolutePath: absolutePath
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
1818
2105
|
}
|
|
1819
2106
|
}
|
|
2107
|
+
return {
|
|
2108
|
+
relativePath,
|
|
2109
|
+
effectiveAbsolutePath
|
|
2110
|
+
};
|
|
2111
|
+
};
|
|
2112
|
+
const emitEvent = async (kind, absolutePath) => {
|
|
2113
|
+
const rawRelPath = normalizePath(getRelativePath(filesDir, absolutePath));
|
|
2114
|
+
if (recentSanitizations.delete(rawRelPath)) {
|
|
2115
|
+
debug(`Suppressing sanitization echo: ${kind} ${rawRelPath}`);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
const resolved = await resolveRelativePath(kind, absolutePath);
|
|
2119
|
+
if (!resolved) return;
|
|
2120
|
+
const { relativePath, effectiveAbsolutePath } = resolved;
|
|
2121
|
+
if (kind === "delete") {
|
|
2122
|
+
const lastHash = contentHashCache.get(relativePath);
|
|
2123
|
+
contentHashCache.delete(relativePath);
|
|
2124
|
+
const samePathPendingAdd = pendingAdds.get(relativePath);
|
|
2125
|
+
if (samePathPendingAdd) {
|
|
2126
|
+
clearTimeout(samePathPendingAdd.timer);
|
|
2127
|
+
pendingAdds.delete(relativePath);
|
|
2128
|
+
try {
|
|
2129
|
+
const latestContent = await fs.readFile(effectiveAbsolutePath, "utf-8");
|
|
2130
|
+
const latestHash = hashFileContent(latestContent);
|
|
2131
|
+
contentHashCache.set(relativePath, latestHash);
|
|
2132
|
+
dispatchEvent({
|
|
2133
|
+
kind: "change",
|
|
2134
|
+
relativePath,
|
|
2135
|
+
content: latestContent
|
|
2136
|
+
});
|
|
2137
|
+
} catch {
|
|
2138
|
+
if (samePathPendingAdd.previousContentHash !== void 0) dispatchEvent({
|
|
2139
|
+
kind: "delete",
|
|
2140
|
+
relativePath
|
|
2141
|
+
});
|
|
2142
|
+
else debug(`Suppressing transient add+delete: ${relativePath}`);
|
|
2143
|
+
}
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
const matchedAdd = matchPendingAddForDelete(lastHash, pendingAdds);
|
|
2147
|
+
if (matchedAdd) {
|
|
2148
|
+
clearTimeout(matchedAdd.pendingAdd.timer);
|
|
2149
|
+
pendingAdds.delete(matchedAdd.key);
|
|
2150
|
+
dispatchEvent({
|
|
2151
|
+
kind: "rename",
|
|
2152
|
+
relativePath: matchedAdd.pendingAdd.relativePath,
|
|
2153
|
+
oldRelativePath: relativePath,
|
|
2154
|
+
content: matchedAdd.pendingAdd.content
|
|
2155
|
+
});
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (lastHash) {
|
|
2159
|
+
const timer = setTimeout(() => {
|
|
2160
|
+
pendingDeletes.delete(relativePath);
|
|
2161
|
+
dispatchEvent({
|
|
2162
|
+
kind: "delete",
|
|
2163
|
+
relativePath
|
|
2164
|
+
});
|
|
2165
|
+
}, RENAME_BUFFER_MS);
|
|
2166
|
+
pendingDeletes.set(relativePath, {
|
|
2167
|
+
relativePath,
|
|
2168
|
+
contentHash: lastHash,
|
|
2169
|
+
timer
|
|
2170
|
+
});
|
|
2171
|
+
} else dispatchEvent({
|
|
2172
|
+
kind: "delete",
|
|
2173
|
+
relativePath
|
|
2174
|
+
});
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
1820
2177
|
let content;
|
|
1821
|
-
|
|
2178
|
+
try {
|
|
1822
2179
|
content = await fs.readFile(effectiveAbsolutePath, "utf-8");
|
|
1823
2180
|
} catch (err) {
|
|
1824
2181
|
debug(`Failed to read file ${relativePath}:`, err);
|
|
1825
2182
|
return;
|
|
1826
2183
|
}
|
|
1827
|
-
const
|
|
2184
|
+
const previousContentHash = contentHashCache.get(relativePath);
|
|
2185
|
+
const contentHash = hashFileContent(content);
|
|
2186
|
+
contentHashCache.set(relativePath, contentHash);
|
|
2187
|
+
if (kind === "add") {
|
|
2188
|
+
const samePathPendingDelete = pendingDeletes.get(relativePath);
|
|
2189
|
+
if (samePathPendingDelete) {
|
|
2190
|
+
clearTimeout(samePathPendingDelete.timer);
|
|
2191
|
+
pendingDeletes.delete(relativePath);
|
|
2192
|
+
dispatchEvent({
|
|
2193
|
+
kind: "change",
|
|
2194
|
+
relativePath,
|
|
2195
|
+
content
|
|
2196
|
+
});
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
const matchedDelete = matchPendingDeleteForAdd(contentHash, pendingDeletes);
|
|
2200
|
+
if (matchedDelete) {
|
|
2201
|
+
clearTimeout(matchedDelete.pendingDelete.timer);
|
|
2202
|
+
pendingDeletes.delete(matchedDelete.key);
|
|
2203
|
+
dispatchEvent({
|
|
2204
|
+
kind: "rename",
|
|
2205
|
+
relativePath,
|
|
2206
|
+
oldRelativePath: matchedDelete.pendingDelete.relativePath,
|
|
2207
|
+
content
|
|
2208
|
+
});
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
const existingPendingAdd = pendingAdds.get(relativePath);
|
|
2212
|
+
if (existingPendingAdd) clearTimeout(existingPendingAdd.timer);
|
|
2213
|
+
const retainedPreviousContentHash = existingPendingAdd ? existingPendingAdd.previousContentHash : previousContentHash;
|
|
2214
|
+
const timer = setTimeout(() => {
|
|
2215
|
+
pendingAdds.delete(relativePath);
|
|
2216
|
+
dispatchEvent({
|
|
2217
|
+
kind: "add",
|
|
2218
|
+
relativePath,
|
|
2219
|
+
content
|
|
2220
|
+
});
|
|
2221
|
+
}, RENAME_BUFFER_MS);
|
|
2222
|
+
pendingAdds.set(relativePath, {
|
|
2223
|
+
relativePath,
|
|
2224
|
+
contentHash,
|
|
2225
|
+
content,
|
|
2226
|
+
timer,
|
|
2227
|
+
previousContentHash: retainedPreviousContentHash
|
|
2228
|
+
});
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
const pendingAdd = pendingAdds.get(relativePath);
|
|
2232
|
+
if (pendingAdd) {
|
|
2233
|
+
clearTimeout(pendingAdd.timer);
|
|
2234
|
+
pendingAdds.delete(relativePath);
|
|
2235
|
+
dispatchEvent({
|
|
2236
|
+
kind: "add",
|
|
2237
|
+
relativePath,
|
|
2238
|
+
content
|
|
2239
|
+
});
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
dispatchEvent({
|
|
1828
2243
|
kind,
|
|
1829
2244
|
relativePath,
|
|
1830
2245
|
content
|
|
1831
|
-
};
|
|
1832
|
-
debug(`Watcher event: ${kind} ${relativePath}`);
|
|
1833
|
-
for (const handler of handlers) handler(event);
|
|
2246
|
+
});
|
|
1834
2247
|
};
|
|
1835
2248
|
watcher.on("add", (filePath) => {
|
|
1836
2249
|
emitEvent("add", filePath);
|
|
@@ -1846,6 +2259,12 @@ function initWatcher(filesDir) {
|
|
|
1846
2259
|
handlers.push(handler);
|
|
1847
2260
|
},
|
|
1848
2261
|
async close() {
|
|
2262
|
+
for (const pending of pendingDeletes.values()) clearTimeout(pending.timer);
|
|
2263
|
+
for (const pending of pendingAdds.values()) clearTimeout(pending.timer);
|
|
2264
|
+
pendingDeletes.clear();
|
|
2265
|
+
pendingAdds.clear();
|
|
2266
|
+
contentHashCache.clear();
|
|
2267
|
+
recentSanitizations.clear();
|
|
1849
2268
|
await watcher.close();
|
|
1850
2269
|
}
|
|
1851
2270
|
};
|
|
@@ -1948,45 +2367,42 @@ var FileMetadataCache = class {
|
|
|
1948
2367
|
function createHashTracker() {
|
|
1949
2368
|
const hashes = /* @__PURE__ */ new Map();
|
|
1950
2369
|
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
2370
|
+
const keyFor = (filePath) => normalizeCodeFilePathWithExtension(filePath);
|
|
1951
2371
|
return {
|
|
1952
2372
|
remember(filePath, content) {
|
|
1953
|
-
const hash =
|
|
1954
|
-
hashes.set(filePath, hash);
|
|
2373
|
+
const hash = hashFileContent(content);
|
|
2374
|
+
hashes.set(keyFor(filePath), hash);
|
|
1955
2375
|
},
|
|
1956
2376
|
shouldSkip(filePath, content) {
|
|
1957
|
-
const currentHash =
|
|
1958
|
-
return hashes.get(filePath) === currentHash;
|
|
2377
|
+
const currentHash = hashFileContent(content);
|
|
2378
|
+
return hashes.get(keyFor(filePath)) === currentHash;
|
|
1959
2379
|
},
|
|
1960
2380
|
forget(filePath) {
|
|
1961
|
-
hashes.delete(filePath);
|
|
2381
|
+
hashes.delete(keyFor(filePath));
|
|
1962
2382
|
},
|
|
1963
2383
|
clear() {
|
|
1964
2384
|
hashes.clear();
|
|
1965
2385
|
},
|
|
1966
2386
|
markDelete(filePath) {
|
|
1967
|
-
const
|
|
2387
|
+
const key = keyFor(filePath);
|
|
2388
|
+
const existingTimer = pendingDeletes.get(key);
|
|
1968
2389
|
if (existingTimer) clearTimeout(existingTimer);
|
|
1969
2390
|
const timeout = setTimeout(() => {
|
|
1970
|
-
pendingDeletes.delete(
|
|
2391
|
+
pendingDeletes.delete(key);
|
|
1971
2392
|
}, 5e3);
|
|
1972
|
-
pendingDeletes.set(
|
|
2393
|
+
pendingDeletes.set(key, timeout);
|
|
1973
2394
|
},
|
|
1974
2395
|
shouldSkipDelete(filePath) {
|
|
1975
|
-
return pendingDeletes.has(filePath);
|
|
2396
|
+
return pendingDeletes.has(keyFor(filePath));
|
|
1976
2397
|
},
|
|
1977
2398
|
clearDelete(filePath) {
|
|
1978
|
-
const
|
|
2399
|
+
const key = keyFor(filePath);
|
|
2400
|
+
const timeout = pendingDeletes.get(key);
|
|
1979
2401
|
if (timeout) clearTimeout(timeout);
|
|
1980
|
-
pendingDeletes.delete(
|
|
2402
|
+
pendingDeletes.delete(key);
|
|
1981
2403
|
}
|
|
1982
2404
|
};
|
|
1983
2405
|
}
|
|
1984
|
-
/**
|
|
1985
|
-
* Computes a SHA256 hash of file content for comparison
|
|
1986
|
-
*/
|
|
1987
|
-
function hashContent(content) {
|
|
1988
|
-
return createHash("sha256").update(content).digest("hex");
|
|
1989
|
-
}
|
|
1990
2406
|
|
|
1991
2407
|
//#endregion
|
|
1992
2408
|
//#region src/utils/project.ts
|
|
@@ -1994,7 +2410,7 @@ function toPackageName(name) {
|
|
|
1994
2410
|
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1995
2411
|
}
|
|
1996
2412
|
function toDirectoryName(name) {
|
|
1997
|
-
return name.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2413
|
+
return name.replace(/[^a-zA-Z0-9 -]/g, "-").trim().replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1998
2414
|
}
|
|
1999
2415
|
async function getProjectHashFromCwd() {
|
|
2000
2416
|
try {
|
|
@@ -2034,7 +2450,7 @@ async function findOrCreateProjectDirectory(options) {
|
|
|
2034
2450
|
shortProjectHash: shortId,
|
|
2035
2451
|
framerProjectName: projectName
|
|
2036
2452
|
};
|
|
2037
|
-
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null,
|
|
2453
|
+
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 4));
|
|
2038
2454
|
return {
|
|
2039
2455
|
directory: projectDirectory,
|
|
2040
2456
|
created: true,
|
|
@@ -2399,6 +2815,21 @@ function transition(state, event) {
|
|
|
2399
2815
|
fileNames: [relativePath]
|
|
2400
2816
|
});
|
|
2401
2817
|
break;
|
|
2818
|
+
case "rename":
|
|
2819
|
+
if (content === void 0 || !event.event.oldRelativePath) {
|
|
2820
|
+
effects.push(log("warn", `Rename event missing data: ${relativePath}`));
|
|
2821
|
+
return {
|
|
2822
|
+
state,
|
|
2823
|
+
effects
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
effects.push(log("debug", `Local rename detected: ${event.event.oldRelativePath} → ${relativePath}`), {
|
|
2827
|
+
type: "SEND_FILE_RENAME",
|
|
2828
|
+
oldFileName: event.event.oldRelativePath,
|
|
2829
|
+
newFileName: relativePath,
|
|
2830
|
+
content
|
|
2831
|
+
});
|
|
2832
|
+
break;
|
|
2402
2833
|
}
|
|
2403
2834
|
return {
|
|
2404
2835
|
state,
|
|
@@ -2487,7 +2918,7 @@ function transition(state, event) {
|
|
|
2487
2918
|
* Returns additional events that should be processed (e.g., CONFLICTS_DETECTED after DETECT_CONFLICTS)
|
|
2488
2919
|
*/
|
|
2489
2920
|
async function executeEffect(effect, context) {
|
|
2490
|
-
const { config, hashTracker, installer, fileMetadataCache, userActions, syncState } = context;
|
|
2921
|
+
const { config, hashTracker, installer, fileMetadataCache, pendingRenameConfirmations, userActions, syncState } = context;
|
|
2491
2922
|
switch (effect.type) {
|
|
2492
2923
|
case "INIT_WORKSPACE":
|
|
2493
2924
|
if (!config.projectDir) {
|
|
@@ -2582,10 +3013,18 @@ async function executeEffect(effect, context) {
|
|
|
2582
3013
|
case "UPDATE_FILE_METADATA": {
|
|
2583
3014
|
if (!config.filesDir || !config.projectDir) return [];
|
|
2584
3015
|
const currentContent = await readFileSafe(effect.fileName, config.filesDir);
|
|
2585
|
-
|
|
2586
|
-
|
|
3016
|
+
const pendingRenameConfirmation = pendingRenameConfirmations.get(normalizeCodeFilePathWithExtension(effect.fileName));
|
|
3017
|
+
const syncedContent = currentContent ?? pendingRenameConfirmation?.content ?? null;
|
|
3018
|
+
if (syncedContent !== null) {
|
|
3019
|
+
const contentHash = hashFileContent(syncedContent);
|
|
2587
3020
|
fileMetadataCache.recordSyncedSnapshot(effect.fileName, contentHash, effect.remoteModifiedAt);
|
|
2588
3021
|
}
|
|
3022
|
+
if (pendingRenameConfirmation) {
|
|
3023
|
+
hashTracker.forget(pendingRenameConfirmation.oldFileName);
|
|
3024
|
+
fileMetadataCache.recordDelete(pendingRenameConfirmation.oldFileName);
|
|
3025
|
+
if (currentContent !== null) hashTracker.remember(effect.fileName, currentContent);
|
|
3026
|
+
pendingRenameConfirmations.delete(normalizeCodeFilePathWithExtension(effect.fileName));
|
|
3027
|
+
}
|
|
2589
3028
|
return [];
|
|
2590
3029
|
}
|
|
2591
3030
|
case "SEND_LOCAL_CHANGE": {
|
|
@@ -2612,6 +3051,37 @@ async function executeEffect(effect, context) {
|
|
|
2612
3051
|
}
|
|
2613
3052
|
return [];
|
|
2614
3053
|
}
|
|
3054
|
+
case "SEND_FILE_RENAME": {
|
|
3055
|
+
const normalizedNewFileName = normalizeCodeFilePathWithExtension(effect.newFileName);
|
|
3056
|
+
if (hashTracker.shouldSkip(normalizedNewFileName, effect.content) && hashTracker.shouldSkipDelete(effect.oldFileName)) {
|
|
3057
|
+
hashTracker.forget(normalizedNewFileName);
|
|
3058
|
+
hashTracker.clearDelete(effect.oldFileName);
|
|
3059
|
+
debug(`Skipping echoed rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3060
|
+
return [];
|
|
3061
|
+
}
|
|
3062
|
+
try {
|
|
3063
|
+
if (!syncState.socket) {
|
|
3064
|
+
warn(`No socket available to send rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3065
|
+
return [];
|
|
3066
|
+
}
|
|
3067
|
+
if (!await sendMessage(syncState.socket, {
|
|
3068
|
+
type: "file-rename",
|
|
3069
|
+
oldFileName: effect.oldFileName,
|
|
3070
|
+
newFileName: normalizedNewFileName,
|
|
3071
|
+
content: effect.content
|
|
3072
|
+
})) {
|
|
3073
|
+
warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3074
|
+
return [];
|
|
3075
|
+
}
|
|
3076
|
+
pendingRenameConfirmations.set(normalizeCodeFilePathWithExtension(effect.newFileName), {
|
|
3077
|
+
oldFileName: effect.oldFileName,
|
|
3078
|
+
content: effect.content
|
|
3079
|
+
});
|
|
3080
|
+
} catch (err) {
|
|
3081
|
+
warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3082
|
+
}
|
|
3083
|
+
return [];
|
|
3084
|
+
}
|
|
2615
3085
|
case "LOCAL_INITIATED_FILE_DELETE": {
|
|
2616
3086
|
const filesToDelete = effect.fileNames.filter((fileName) => {
|
|
2617
3087
|
const shouldSkip = hashTracker.shouldSkipDelete(fileName);
|
|
@@ -2682,6 +3152,7 @@ async function start(config) {
|
|
|
2682
3152
|
status("Waiting for Plugin connection...");
|
|
2683
3153
|
const hashTracker = createHashTracker();
|
|
2684
3154
|
const fileMetadataCache = new FileMetadataCache();
|
|
3155
|
+
const pendingRenameConfirmations = /* @__PURE__ */ new Map();
|
|
2685
3156
|
let installer = null;
|
|
2686
3157
|
let syncState = {
|
|
2687
3158
|
mode: "disconnected",
|
|
@@ -2703,13 +3174,24 @@ async function start(config) {
|
|
|
2703
3174
|
hashTracker,
|
|
2704
3175
|
installer,
|
|
2705
3176
|
fileMetadataCache,
|
|
3177
|
+
pendingRenameConfirmations,
|
|
2706
3178
|
userActions,
|
|
2707
3179
|
syncState
|
|
2708
3180
|
});
|
|
2709
3181
|
for (const followUpEvent of followUpEvents) await processEvent(followUpEvent);
|
|
2710
3182
|
}
|
|
2711
3183
|
}
|
|
2712
|
-
const
|
|
3184
|
+
const certs = await getOrCreateCerts();
|
|
3185
|
+
if (!certs) {
|
|
3186
|
+
error("Failed to generate TLS certificates. The Framer plugin requires a secure (wss://) connection.");
|
|
3187
|
+
info("");
|
|
3188
|
+
info("To fix this:");
|
|
3189
|
+
info(" 1. Re-run this command — certificate generation is often a one-time issue");
|
|
3190
|
+
info(` 2. Manually delete "${String(CERT_DIR)}" and try again`);
|
|
3191
|
+
info("");
|
|
3192
|
+
throw new Error("TLS certificate generation failed");
|
|
3193
|
+
}
|
|
3194
|
+
const connection = await initConnection(config.port, certs);
|
|
2713
3195
|
connection.on("handshake", (client, message) => {
|
|
2714
3196
|
debug(`Received handshake: ${message.projectName} (${message.projectId})`);
|
|
2715
3197
|
const expectedShort = shortProjectHash(config.projectHash);
|
|
@@ -2727,6 +3209,7 @@ async function start(config) {
|
|
|
2727
3209
|
return;
|
|
2728
3210
|
}
|
|
2729
3211
|
debug(`New handshake received in ${syncState.mode} mode, resetting sync state`);
|
|
3212
|
+
pendingRenameConfirmations.clear();
|
|
2730
3213
|
await processEvent({ type: "DISCONNECT" });
|
|
2731
3214
|
}
|
|
2732
3215
|
if (!wasRecentlyDisconnected() && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
|
|
@@ -2808,6 +3291,10 @@ async function start(config) {
|
|
|
2808
3291
|
remoteModifiedAt: message.remoteModifiedAt
|
|
2809
3292
|
};
|
|
2810
3293
|
break;
|
|
3294
|
+
case "error":
|
|
3295
|
+
if (message.fileName) pendingRenameConfirmations.delete(normalizeCodeFilePathWithExtension(message.fileName));
|
|
3296
|
+
warn(message.message);
|
|
3297
|
+
return;
|
|
2811
3298
|
case "conflicts-resolved":
|
|
2812
3299
|
event = {
|
|
2813
3300
|
type: "CONFLICTS_RESOLVED",
|
|
@@ -2844,6 +3331,7 @@ async function start(config) {
|
|
|
2844
3331
|
status("Disconnected, waiting to reconnect...");
|
|
2845
3332
|
});
|
|
2846
3333
|
(async () => {
|
|
3334
|
+
pendingRenameConfirmations.clear();
|
|
2847
3335
|
await processEvent({ type: "DISCONNECT" });
|
|
2848
3336
|
userActions.cleanup();
|
|
2849
3337
|
})();
|