framer-code-link 0.2.0 → 0.2.1

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.
@@ -1,187 +0,0 @@
1
- /**
2
- * WebSocket connection helper
3
- *
4
- * Thin wrapper around ws.Server that normalizes handshake and surfaces
5
- * simple callbacks. Keeps raw socket API localized.
6
- */
7
-
8
- import { WebSocketServer, WebSocket } from "ws"
9
- import type { IncomingMessage, OutgoingMessage } from "../types.js"
10
- import { debug, error } from "../utils/logging.js"
11
-
12
- export interface ConnectionCallbacks {
13
- onHandshake: (
14
- client: WebSocket,
15
- message: { projectId: string; projectName: string }
16
- ) => void
17
- onMessage: (message: IncomingMessage) => void
18
- onDisconnect: () => void
19
- onError: (error: Error) => void
20
- }
21
-
22
- export interface Connection {
23
- on(event: "handshake", handler: ConnectionCallbacks["onHandshake"]): void
24
- on(event: "message", handler: ConnectionCallbacks["onMessage"]): void
25
- on(event: "disconnect", handler: ConnectionCallbacks["onDisconnect"]): void
26
- on(event: "error", handler: ConnectionCallbacks["onError"]): void
27
- close(): void
28
- }
29
-
30
- /**
31
- * Initializes a WebSocket server and returns a connection interface
32
- * Returns a Promise that resolves when the server is ready, or rejects on startup errors
33
- */
34
- export function initConnection(port: number): Promise<Connection> {
35
- return new Promise((resolve, reject) => {
36
- const wss = new WebSocketServer({ port })
37
- const handlers: Partial<ConnectionCallbacks> = {}
38
- let connectionId = 0
39
- let isReady = false
40
-
41
- // Handle server-level errors (e.g., EADDRINUSE)
42
- wss.on("error", (err: NodeJS.ErrnoException) => {
43
- if (!isReady) {
44
- // Startup error - reject the promise with a helpful message
45
- if (err.code === "EADDRINUSE") {
46
- error(`Port ${port} is already in use.`)
47
- error(
48
- `This usually means another instance of Code Link is already running.`
49
- )
50
- error(``)
51
- error(`To fix this:`)
52
- error(
53
- ` 1. Close any other terminal running Code Link for this project`
54
- )
55
- error(` 2. Or run: lsof -i :${port} | grep LISTEN`)
56
- error(` Then kill the process: kill -9 <PID>`)
57
- reject(new Error(`Port ${port} is already in use`))
58
- } else {
59
- error(`Failed to start WebSocket server: ${err.message}`)
60
- reject(err)
61
- }
62
- return
63
- }
64
- // Runtime error - log but don't crash
65
- error(`WebSocket server error: ${err.message}`)
66
- })
67
-
68
- // Server is ready when it starts listening
69
- wss.on("listening", () => {
70
- isReady = true
71
- debug(`WebSocket server listening on port ${port}`)
72
-
73
- wss.on("connection", (ws: WebSocket) => {
74
- const connId = ++connectionId
75
- let handshakeReceived = false
76
- debug(`Client connected (conn ${connId})`)
77
-
78
- ws.on("message", (data: Buffer) => {
79
- try {
80
- const message = JSON.parse(data.toString()) as IncomingMessage
81
-
82
- // Special handling for handshake
83
- if (message.type === "handshake") {
84
- debug(`Received handshake (conn ${connId})`)
85
- handshakeReceived = true
86
- handlers.onHandshake?.(ws, message)
87
- } else if (handshakeReceived) {
88
- handlers.onMessage?.(message)
89
- } else {
90
- // Ignore messages before handshake - plugin will send full snapshot after
91
- debug(
92
- `Ignoring ${message.type} before handshake (conn ${connId})`
93
- )
94
- }
95
- } catch (err) {
96
- error(`Failed to parse message:`, err)
97
- }
98
- })
99
-
100
- ws.on("close", (code, reason) => {
101
- debug(
102
- `Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`
103
- )
104
- handlers.onDisconnect?.()
105
- })
106
-
107
- ws.on("error", (err) => {
108
- error(`WebSocket error:`, err)
109
- })
110
- })
111
-
112
- resolve({
113
- on(
114
- event: "handshake" | "message" | "disconnect" | "error",
115
- handler: any
116
- ): void {
117
- if (event === "handshake") {
118
- handlers.onHandshake = handler
119
- } else if (event === "message") {
120
- handlers.onMessage = handler
121
- } else if (event === "disconnect") {
122
- handlers.onDisconnect = handler
123
- } else if (event === "error") {
124
- handlers.onError = handler
125
- }
126
- },
127
-
128
- close(): void {
129
- wss.close()
130
- },
131
- })
132
- })
133
- })
134
- }
135
-
136
- /**
137
- * WebSocket readyState constants for reference
138
- */
139
- const READY_STATE = {
140
- CONNECTING: 0,
141
- OPEN: 1,
142
- CLOSING: 2,
143
- CLOSED: 3,
144
- } as const
145
-
146
- function readyStateToString(state: number): string {
147
- switch (state) {
148
- case 0:
149
- return "CONNECTING"
150
- case 1:
151
- return "OPEN"
152
- case 2:
153
- return "CLOSING"
154
- case 3:
155
- return "CLOSED"
156
- default:
157
- return `UNKNOWN(${state})`
158
- }
159
- }
160
-
161
- /**
162
- * Sends a message to a connected socket
163
- * Returns false if the socket is not open (instead of throwing)
164
- */
165
- export function sendMessage(
166
- socket: WebSocket,
167
- message: OutgoingMessage
168
- ): Promise<boolean> {
169
- return new Promise((resolve) => {
170
- // Check socket state before attempting to send
171
- if (socket.readyState !== READY_STATE.OPEN) {
172
- const stateStr = readyStateToString(socket.readyState)
173
- debug(`Cannot send ${message.type}: socket is ${stateStr}`)
174
- resolve(false)
175
- return
176
- }
177
-
178
- socket.send(JSON.stringify(message), (err) => {
179
- if (err) {
180
- debug(`Send error for ${message.type}: ${err.message}`)
181
- resolve(false)
182
- } else {
183
- resolve(true)
184
- }
185
- })
186
- })
187
- }
@@ -1,117 +0,0 @@
1
- import fs from "fs/promises"
2
- import os from "os"
3
- import path from "path"
4
- import { describe, it, expect } from "vitest"
5
- import { autoResolveConflicts, detectConflicts } from "./files.js"
6
- import type { Conflict } from "../types.js"
7
- import { hashFileContent } from "../utils/state-persistence.js"
8
-
9
- function makeConflict(overrides: Partial<Conflict> = {}): Conflict {
10
- return {
11
- fileName: overrides.fileName ?? "Test.tsx",
12
- localContent: overrides.localContent ?? "local",
13
- remoteContent: overrides.remoteContent ?? "remote",
14
- localModifiedAt: overrides.localModifiedAt ?? Date.now(),
15
- remoteModifiedAt: overrides.remoteModifiedAt ?? Date.now(),
16
- lastSyncedAt: overrides.lastSyncedAt ?? Date.now(),
17
- localClean: overrides.localClean,
18
- }
19
- }
20
-
21
- describe("autoResolveConflicts", () => {
22
- it("classifies conflicts as local when remote unchanged and local changed", () => {
23
- const conflict = makeConflict({
24
- lastSyncedAt: 5_000,
25
- localClean: false,
26
- })
27
-
28
- const result = autoResolveConflicts(
29
- [conflict],
30
- [{ fileName: conflict.fileName, latestRemoteVersionMs: 5_000 }]
31
- )
32
-
33
- expect(result.autoResolvedLocal).toHaveLength(1)
34
- expect(result.autoResolvedRemote).toHaveLength(0)
35
- expect(result.remainingConflicts).toHaveLength(0)
36
- })
37
-
38
- it("classifies conflicts as remote when local is clean and remote changed", () => {
39
- const conflict = makeConflict({
40
- lastSyncedAt: 5_000,
41
- localClean: true,
42
- })
43
-
44
- const result = autoResolveConflicts(
45
- [conflict],
46
- [{ fileName: conflict.fileName, latestRemoteVersionMs: 10_000 }]
47
- )
48
-
49
- expect(result.autoResolvedRemote).toHaveLength(1)
50
- expect(result.autoResolvedLocal).toHaveLength(0)
51
- })
52
-
53
- it("keeps conflicts that have both sides changed", () => {
54
- const conflict = makeConflict({
55
- lastSyncedAt: 5_000,
56
- localClean: false,
57
- })
58
-
59
- const result = autoResolveConflicts(
60
- [conflict],
61
- [{ fileName: conflict.fileName, latestRemoteVersionMs: 7_500 }]
62
- )
63
-
64
- expect(result.remainingConflicts).toHaveLength(1)
65
- expect(result.autoResolvedLocal).toHaveLength(0)
66
- expect(result.autoResolvedRemote).toHaveLength(0)
67
- })
68
-
69
- it("keeps conflicts when version data is missing", () => {
70
- const conflict = makeConflict({
71
- lastSyncedAt: 5_000,
72
- localClean: true,
73
- })
74
-
75
- const result = autoResolveConflicts([conflict], [])
76
-
77
- expect(result.remainingConflicts).toHaveLength(1)
78
- })
79
- })
80
-
81
- describe("detectConflicts", () => {
82
- it("marks conflicts as localClean when local matches persisted state", async () => {
83
- const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-"))
84
- try {
85
- const filesDir = path.join(tmpRoot, "files")
86
- await fs.mkdir(filesDir, { recursive: true })
87
-
88
- const localContent = "local content"
89
- await fs.writeFile(path.join(filesDir, "Test.tsx"), localContent, "utf-8")
90
-
91
- const persistedState = new Map([
92
- [
93
- "Test.tsx",
94
- { contentHash: hashFileContent(localContent), timestamp: 1_000 },
95
- ],
96
- ])
97
-
98
- const result = await detectConflicts(
99
- [
100
- {
101
- name: "Test.tsx",
102
- content: "remote content",
103
- modifiedAt: 2_000,
104
- },
105
- ],
106
- filesDir,
107
- { persistedState }
108
- )
109
-
110
- expect(result.writes).toHaveLength(0)
111
- expect(result.conflicts).toHaveLength(1)
112
- expect(result.conflicts[0]?.localClean).toBe(true)
113
- } finally {
114
- await fs.rm(tmpRoot, { recursive: true, force: true })
115
- }
116
- })
117
- })