framer-code-link 0.2.0 → 0.2.2

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/src/controller.ts DELETED
@@ -1,1419 +0,0 @@
1
- /**
2
- * Controller
3
- * Single source of truth for all runtime state and orchestrates the sync lifecycle.
4
- * Helpers are functions that provide data - they never hold control or callbacks.
5
- */
6
-
7
- import fs from "fs/promises"
8
- import type { WebSocket } from "ws"
9
- import type {
10
- Config,
11
- IncomingMessage,
12
- OutgoingMessage,
13
- FileInfo,
14
- Conflict,
15
- WatcherEvent,
16
- ConflictVersionData,
17
- } from "./types.js"
18
- import { initConnection, sendMessage } from "./helpers/connection.js"
19
- import { initWatcher } from "./helpers/watcher.js"
20
- import {
21
- listFiles,
22
- detectConflicts,
23
- writeRemoteFiles,
24
- deleteLocalFile,
25
- readFileSafe,
26
- autoResolveConflicts,
27
- } from "./helpers/files.js"
28
- import { Installer } from "./helpers/installer.js"
29
- import { createHashTracker } from "./utils/hash-tracker.js"
30
- import {
31
- info,
32
- warn,
33
- error,
34
- success,
35
- debug,
36
- status,
37
- fileDown,
38
- fileUp,
39
- fileDelete,
40
- scheduleDisconnectMessage,
41
- cancelDisconnectMessage,
42
- didShowDisconnect,
43
- wasRecentlyDisconnected,
44
- resetDisconnectState,
45
- } from "./utils/logging.js"
46
- import { hashFileContent } from "./utils/state-persistence.js"
47
- import {
48
- FileMetadataCache,
49
- type FileSyncMetadata,
50
- } from "./utils/file-metadata-cache.js"
51
- import { UserActionCoordinator } from "./helpers/user-actions.js"
52
- import { validateIncomingChange } from "./helpers/sync-validator.js"
53
- import { findOrCreateProjectDir } from "./utils/project.js"
54
- import { pluralize, shortProjectHash } from "@code-link/shared"
55
-
56
- /**
57
- * Explicit sync lifecycle modes
58
- */
59
- export type SyncMode =
60
- | "disconnected"
61
- | "handshaking"
62
- | "snapshot_processing"
63
- | "conflict_resolution"
64
- | "watching"
65
-
66
- /**
67
- * Pending operation for echo suppression and replay
68
- */
69
- type PendingOperation =
70
- | { id: string; type: "write"; file: string; hash: string }
71
- | { id: string; type: "delete"; file: string; previousHash?: string }
72
-
73
- /**
74
- * Shared state that persists across all lifecycle modes
75
- */
76
- interface SyncStateBase {
77
- queuedDiffs: FileInfo[]
78
- pendingOperations: Map<string, PendingOperation>
79
- nextOperationId: number
80
- }
81
-
82
- type DisconnectedState = SyncStateBase & {
83
- mode: "disconnected"
84
- socket: null
85
- }
86
-
87
- type HandshakingState = SyncStateBase & {
88
- mode: "handshaking"
89
- socket: WebSocket
90
- }
91
-
92
- type SnapshotProcessingState = SyncStateBase & {
93
- mode: "snapshot_processing"
94
- socket: WebSocket
95
- }
96
-
97
- type ConflictResolutionState = SyncStateBase & {
98
- mode: "conflict_resolution"
99
- socket: WebSocket
100
- pendingConflicts: Conflict[]
101
- }
102
-
103
- type WatchingState = SyncStateBase & {
104
- mode: "watching"
105
- socket: WebSocket
106
- }
107
-
108
- export type SyncState =
109
- | DisconnectedState
110
- | HandshakingState
111
- | SnapshotProcessingState
112
- | ConflictResolutionState
113
- | WatchingState
114
-
115
- /**
116
- * Events that drive state transitions
117
- */
118
- type SyncEvent =
119
- | {
120
- type: "HANDSHAKE"
121
- socket: WebSocket
122
- projectInfo: { projectId: string; projectName: string }
123
- }
124
- | { type: "REQUEST_FILES" }
125
- | { type: "FILE_LIST"; files: FileInfo[] }
126
- | {
127
- type: "CONFLICTS_DETECTED"
128
- conflicts: Conflict[]
129
- safeWrites: FileInfo[]
130
- localOnly: FileInfo[]
131
- }
132
- | { type: "FILE_CHANGE"; file: FileInfo; fileMeta?: FileSyncMetadata }
133
- | { type: "REMOTE_FILE_DELETE"; fileName: string }
134
- | { type: "REMOTE_DELETE_CONFIRMED"; fileName: string }
135
- | { type: "REMOTE_DELETE_CANCELLED"; fileName: string; content: string }
136
- | {
137
- type: "CONFLICTS_RESOLVED"
138
- resolution: "local" | "remote"
139
- }
140
- | {
141
- type: "FILE_SYNCED"
142
- fileName: string
143
- remoteModifiedAt: number
144
- }
145
- | { type: "DISCONNECT" }
146
- | { type: "WATCHER_EVENT"; event: WatcherEvent }
147
- | {
148
- type: "CONFLICT_VERSION_RESPONSE"
149
- versions: ConflictVersionData[]
150
- }
151
-
152
- /**
153
- * Side effects emitted by transitions
154
- */
155
- type Effect =
156
- | {
157
- type: "INIT_WORKSPACE"
158
- projectInfo: { projectId: string; projectName: string }
159
- }
160
- | { type: "LOAD_PERSISTED_STATE" }
161
- | { type: "SEND_MESSAGE"; payload: OutgoingMessage }
162
- | { type: "LIST_LOCAL_FILES" }
163
- | { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
164
- | {
165
- type: "WRITE_FILES"
166
- files: FileInfo[]
167
- silent?: boolean
168
- skipEcho?: boolean // when true, skip writes that match hashTracker (inbound echo)
169
- }
170
- | { type: "DELETE_LOCAL_FILES"; names: string[] }
171
- | { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
172
- | { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
173
- | {
174
- type: "REQUEST_DELETE_CONFIRMATION"
175
- fileName: string
176
- requireConfirmation: boolean
177
- }
178
- | {
179
- type: "UPDATE_FILE_METADATA"
180
- fileName: string
181
- remoteModifiedAt: number
182
- }
183
- | {
184
- type: "SEND_LOCAL_CHANGE"
185
- fileName: string
186
- content: string
187
- }
188
- | {
189
- type: "REQUEST_LOCAL_DELETE_DECISION"
190
- fileName: string
191
- requireConfirmation: boolean
192
- }
193
- | { type: "PERSIST_STATE" }
194
- | {
195
- type: "SYNC_COMPLETE"
196
- totalCount: number
197
- updatedCount: number
198
- unchangedCount: number
199
- }
200
- | { type: "LOG"; level: "info" | "debug" | "warn"; message: string }
201
-
202
- /** Log helper */
203
- function log(level: "info" | "debug" | "warn", message: string): Effect {
204
- return { type: "LOG", level, message }
205
- }
206
-
207
- /**
208
- * Filter out files whose content matches the last remembered hash.
209
- * Used to skip inbound echoes of our own local sends.
210
- */
211
- export function filterEchoedFiles(
212
- files: FileInfo[],
213
- hashTracker: ReturnType<typeof createHashTracker>
214
- ): FileInfo[] {
215
- return files.filter((file) => {
216
- if (file.content === undefined) return true
217
- return !hashTracker.shouldSkip(file.name, file.content)
218
- })
219
- }
220
-
221
- /**
222
- * Pure state transition function
223
- * Takes current state + event, returns new state + effects to execute
224
- */
225
- function transition(
226
- state: SyncState,
227
- event: SyncEvent
228
- ): { state: SyncState; effects: Effect[] } {
229
- const effects: Effect[] = []
230
-
231
- switch (event.type) {
232
- case "HANDSHAKE": {
233
- if (state.mode !== "disconnected") {
234
- effects.push(
235
- log("warn", `Received HANDSHAKE in mode ${state.mode}, ignoring`)
236
- )
237
- return { state, effects }
238
- }
239
-
240
- effects.push(
241
- { type: "INIT_WORKSPACE", projectInfo: event.projectInfo },
242
- { type: "LOAD_PERSISTED_STATE" },
243
- { type: "SEND_MESSAGE", payload: { type: "request-files" } }
244
- )
245
-
246
- return {
247
- state: {
248
- ...state,
249
- mode: "handshaking",
250
- socket: event.socket,
251
- },
252
- effects,
253
- }
254
- }
255
-
256
- case "FILE_SYNCED": {
257
- // Remote confirms they received our local change
258
- effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
259
- type: "UPDATE_FILE_METADATA",
260
- fileName: event.fileName,
261
- remoteModifiedAt: event.remoteModifiedAt,
262
- })
263
-
264
- return { state, effects }
265
- }
266
-
267
- case "DISCONNECT": {
268
- effects.push(
269
- { type: "PERSIST_STATE" },
270
- log("debug", "Disconnected, persisting state")
271
- )
272
-
273
- if (state.mode === "conflict_resolution") {
274
- const { pendingConflicts: _discarded, ...rest } = state
275
- return {
276
- state: {
277
- ...rest,
278
- mode: "disconnected",
279
- socket: null,
280
- },
281
- effects,
282
- }
283
- }
284
-
285
- return {
286
- state: {
287
- ...state,
288
- mode: "disconnected",
289
- socket: null,
290
- },
291
- effects,
292
- }
293
- }
294
-
295
- case "REQUEST_FILES": {
296
- // Plugin is asking for our local file list
297
- // Valid in any mode except disconnected
298
- if (state.mode === "disconnected") {
299
- effects.push(
300
- log("warn", "Received REQUEST_FILES while disconnected, ignoring")
301
- )
302
- return { state, effects }
303
- }
304
-
305
- effects.push(log("debug", "Plugin requested file list"), {
306
- type: "LIST_LOCAL_FILES",
307
- })
308
-
309
- return { state, effects }
310
- }
311
-
312
- case "FILE_LIST": {
313
- if (state.mode !== "handshaking") {
314
- effects.push(
315
- log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`)
316
- )
317
- return { state, effects }
318
- }
319
-
320
- effects.push(
321
- log("debug", `Received file list: ${event.files.length} files`)
322
- )
323
-
324
- // Detect conflicts between remote snapshot and local files
325
- effects.push({
326
- type: "DETECT_CONFLICTS",
327
- remoteFiles: event.files,
328
- })
329
-
330
- // Transition to snapshot_processing - conflict detection effect will determine next mode
331
- return {
332
- state: {
333
- ...state,
334
- mode: "snapshot_processing",
335
- queuedDiffs: event.files,
336
- },
337
- effects,
338
- }
339
- }
340
-
341
- case "CONFLICTS_DETECTED": {
342
- if (state.mode !== "snapshot_processing") {
343
- effects.push(
344
- log(
345
- "warn",
346
- `Received CONFLICTS_DETECTED in mode ${state.mode}, ignoring`
347
- )
348
- )
349
- return { state, effects }
350
- }
351
-
352
- const { conflicts, safeWrites, localOnly } = event
353
-
354
- // detectConflicts returns:
355
- // - safeWrites = files we can apply (remote-only or local unchanged)
356
- // - conflicts = files that need manual resolution (content or deletion conflicts)
357
- // - localOnly = files to upload
358
- // (unchanged files have metadata recorded in DETECT_CONFLICTS executor)
359
-
360
- // Apply safe writes
361
- if (safeWrites.length > 0) {
362
- effects.push(
363
- log("debug", `Applying ${safeWrites.length} safe writes`),
364
- { type: "WRITE_FILES", files: safeWrites, silent: true }
365
- )
366
- }
367
-
368
- // Upload local-only files
369
- if (localOnly.length > 0) {
370
- effects.push(
371
- log("debug", `Uploading ${localOnly.length} local-only files`)
372
- )
373
- for (const file of localOnly) {
374
- effects.push({
375
- type: "SEND_MESSAGE",
376
- payload: {
377
- type: "file-change",
378
- fileName: file.name,
379
- content: file.content,
380
- },
381
- })
382
- }
383
- }
384
-
385
- // If conflicts remain, request remote version data before surfacing to user
386
- if (conflicts.length > 0) {
387
- effects.push(
388
- log(
389
- "debug",
390
- `${pluralize(conflicts.length, "conflict")} require version check`
391
- ),
392
- { type: "REQUEST_CONFLICT_VERSIONS", conflicts }
393
- )
394
-
395
- return {
396
- state: {
397
- ...state,
398
- mode: "conflict_resolution",
399
- pendingConflicts: conflicts,
400
- },
401
- effects,
402
- }
403
- }
404
-
405
- // No conflicts - transition to watching
406
- const remoteTotal = state.queuedDiffs.length
407
- const totalCount = remoteTotal + localOnly.length
408
- const updatedCount = safeWrites.length + localOnly.length
409
- const unchangedCount = Math.max(0, remoteTotal - safeWrites.length)
410
- effects.push(
411
- { type: "PERSIST_STATE" },
412
- {
413
- type: "SYNC_COMPLETE",
414
- totalCount,
415
- updatedCount,
416
- unchangedCount,
417
- }
418
- )
419
-
420
- return {
421
- state: {
422
- ...state,
423
- mode: "watching",
424
- queuedDiffs: [],
425
- },
426
- effects,
427
- }
428
- }
429
-
430
- case "FILE_CHANGE": {
431
- // Use helper to validate the incoming change
432
- const validation = validateIncomingChange(
433
- event.file,
434
- event.fileMeta,
435
- state.mode
436
- )
437
-
438
- if (validation.action === "queue") {
439
- effects.push(
440
- log(
441
- "debug",
442
- `Queueing file change: ${event.file.name} (${validation.reason})`
443
- )
444
- )
445
-
446
- return {
447
- state: {
448
- ...state,
449
- queuedDiffs: [...state.queuedDiffs, event.file],
450
- },
451
- effects,
452
- }
453
- }
454
-
455
- if (validation.action === "reject") {
456
- effects.push(
457
- log(
458
- "warn",
459
- `Rejected file change: ${event.file.name} (${validation.reason})`
460
- )
461
- )
462
- return { state, effects }
463
- }
464
-
465
- // Apply the change
466
- effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
467
- type: "WRITE_FILES",
468
- files: [event.file],
469
- skipEcho: true,
470
- })
471
-
472
- return { state, effects }
473
- }
474
-
475
- case "REMOTE_FILE_DELETE": {
476
- // Reject if not connected
477
- if (state.mode === "disconnected") {
478
- effects.push(
479
- log("warn", `Rejected delete while disconnected: ${event.fileName}`)
480
- )
481
- return { state, effects }
482
- }
483
-
484
- // Remote deletes should always be applied immediately
485
- // (the file is already gone from Framer)
486
- effects.push(
487
- log("debug", `Remote delete applied: ${event.fileName}`),
488
- { type: "DELETE_LOCAL_FILES", names: [event.fileName] },
489
- { type: "PERSIST_STATE" }
490
- )
491
-
492
- return { state, effects }
493
- }
494
-
495
- case "REMOTE_DELETE_CONFIRMED": {
496
- // User confirmed the delete - apply it
497
- effects.push(
498
- log("debug", `Delete confirmed: ${event.fileName}`),
499
- { type: "DELETE_LOCAL_FILES", names: [event.fileName] },
500
- { type: "PERSIST_STATE" }
501
- )
502
-
503
- return { state, effects }
504
- }
505
-
506
- case "REMOTE_DELETE_CANCELLED": {
507
- // User cancelled - restore the file
508
- effects.push(log("debug", `Delete cancelled: ${event.fileName}`))
509
- effects.push({
510
- type: "WRITE_FILES",
511
- files: [
512
- {
513
- name: event.fileName,
514
- content: event.content,
515
- modifiedAt: Date.now(),
516
- },
517
- ],
518
- })
519
-
520
- return { state, effects }
521
- }
522
-
523
- case "CONFLICTS_RESOLVED": {
524
- // Only valid in conflict_resolution mode
525
- if (state.mode !== "conflict_resolution") {
526
- effects.push(
527
- log(
528
- "warn",
529
- `Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring`
530
- )
531
- )
532
- return { state, effects }
533
- }
534
-
535
- // User picked one resolution for ALL conflicts
536
- if (event.resolution === "remote") {
537
- // Apply all remote versions (or delete locally if remote is null)
538
- for (const conflict of state.pendingConflicts) {
539
- if (conflict.remoteContent === null) {
540
- // Remote deleted this file - delete locally
541
- effects.push({
542
- type: "DELETE_LOCAL_FILES",
543
- names: [conflict.fileName],
544
- })
545
- } else {
546
- effects.push({
547
- type: "WRITE_FILES",
548
- files: [
549
- {
550
- name: conflict.fileName,
551
- content: conflict.remoteContent,
552
- modifiedAt: conflict.remoteModifiedAt,
553
- },
554
- ],
555
- })
556
- }
557
- }
558
- effects.push(
559
- log(
560
- "debug",
561
- `Applied ${state.pendingConflicts.length} remote versions`
562
- )
563
- )
564
- } else {
565
- // Send all local versions (or delete from Framer if local is null)
566
- for (const conflict of state.pendingConflicts) {
567
- if (conflict.localContent === null) {
568
- // Local deleted this file - delete from Framer
569
- effects.push({
570
- type: "SEND_MESSAGE",
571
- payload: {
572
- type: "file-delete",
573
- fileNames: [conflict.fileName],
574
- },
575
- })
576
- } else {
577
- effects.push({
578
- type: "SEND_MESSAGE",
579
- payload: {
580
- type: "file-change",
581
- fileName: conflict.fileName,
582
- content: conflict.localContent,
583
- },
584
- })
585
- }
586
- }
587
- effects.push(
588
- log(
589
- "debug",
590
- `Applied ${state.pendingConflicts.length} local versions`
591
- )
592
- )
593
- }
594
-
595
- // All conflicts resolved - transition to watching
596
- effects.push(
597
- { type: "PERSIST_STATE" },
598
- {
599
- type: "SYNC_COMPLETE",
600
- totalCount: state.pendingConflicts.length,
601
- updatedCount: state.pendingConflicts.length,
602
- unchangedCount: 0,
603
- }
604
- )
605
-
606
- const { pendingConflicts: _discarded, ...rest } = state
607
- return {
608
- state: {
609
- ...rest,
610
- mode: "watching",
611
- },
612
- effects,
613
- }
614
- }
615
-
616
- case "WATCHER_EVENT": {
617
- // Local file system change detected
618
- const { kind, relativePath, content } = event.event
619
-
620
- // Only process changes in watching mode
621
- if (state.mode !== "watching") {
622
- effects.push(
623
- log(
624
- "debug",
625
- `Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}`
626
- )
627
- )
628
- return { state, effects }
629
- }
630
-
631
- switch (kind) {
632
- case "add":
633
- case "change": {
634
- if (content === undefined) {
635
- effects.push(
636
- log("warn", `Watcher event missing content: ${relativePath}`)
637
- )
638
- return { state, effects }
639
- }
640
-
641
- effects.push({
642
- type: "SEND_LOCAL_CHANGE",
643
- fileName: relativePath,
644
- content,
645
- })
646
- break
647
- }
648
-
649
- case "delete": {
650
- effects.push(log("debug", `Local delete detected: ${relativePath}`), {
651
- type: "REQUEST_LOCAL_DELETE_DECISION",
652
- fileName: relativePath,
653
- requireConfirmation: true, // Will be overridden by config in effect
654
- })
655
- break
656
- }
657
- }
658
-
659
- return { state, effects }
660
- }
661
-
662
- case "CONFLICT_VERSION_RESPONSE": {
663
- if (state.mode !== "conflict_resolution") {
664
- effects.push(
665
- log(
666
- "warn",
667
- `Received CONFLICT_VERSION_RESPONSE in mode ${state.mode}, ignoring`
668
- )
669
- )
670
- return { state, effects }
671
- }
672
-
673
- const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } =
674
- autoResolveConflicts(state.pendingConflicts, event.versions)
675
-
676
- if (autoResolvedLocal.length > 0) {
677
- effects.push(
678
- log(
679
- "debug",
680
- `Auto-resolved ${autoResolvedLocal.length} local changes`
681
- )
682
- )
683
- for (const conflict of autoResolvedLocal) {
684
- if (conflict.localContent === null) {
685
- // Local deleted - delete from Framer
686
- effects.push({
687
- type: "SEND_MESSAGE",
688
- payload: {
689
- type: "file-delete",
690
- fileNames: [conflict.fileName],
691
- },
692
- })
693
- } else {
694
- effects.push({
695
- type: "SEND_LOCAL_CHANGE",
696
- fileName: conflict.fileName,
697
- content: conflict.localContent,
698
- })
699
- }
700
- }
701
- }
702
-
703
- if (autoResolvedRemote.length > 0) {
704
- effects.push(
705
- log(
706
- "debug",
707
- `Auto-resolved ${autoResolvedRemote.length} remote changes`
708
- )
709
- )
710
- for (const conflict of autoResolvedRemote) {
711
- if (conflict.remoteContent === null) {
712
- // Remote deleted - delete locally
713
- effects.push({
714
- type: "DELETE_LOCAL_FILES",
715
- names: [conflict.fileName],
716
- })
717
- } else {
718
- effects.push({
719
- type: "WRITE_FILES",
720
- files: [
721
- {
722
- name: conflict.fileName,
723
- content: conflict.remoteContent,
724
- modifiedAt: conflict.remoteModifiedAt ?? Date.now(),
725
- },
726
- ],
727
- })
728
- }
729
- }
730
- }
731
-
732
- if (remainingConflicts.length > 0) {
733
- effects.push(
734
- log(
735
- "warn",
736
- `${pluralize(remainingConflicts.length, "conflict")} require resolution`
737
- ),
738
- { type: "REQUEST_CONFLICT_DECISIONS", conflicts: remainingConflicts }
739
- )
740
-
741
- return {
742
- state: {
743
- ...state,
744
- pendingConflicts: remainingConflicts,
745
- },
746
- effects,
747
- }
748
- }
749
-
750
- const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length
751
- effects.push(
752
- { type: "PERSIST_STATE" },
753
- {
754
- type: "SYNC_COMPLETE",
755
- totalCount: resolvedCount,
756
- updatedCount: resolvedCount,
757
- unchangedCount: 0,
758
- }
759
- )
760
-
761
- const { pendingConflicts: _discarded, ...rest } = state
762
- return {
763
- state: {
764
- ...rest,
765
- mode: "watching",
766
- queuedDiffs: [],
767
- },
768
- effects,
769
- }
770
- }
771
-
772
- default: {
773
- effects.push(log("warn", `Unhandled event type in transition`))
774
- return { state, effects }
775
- }
776
- }
777
- }
778
-
779
- /**
780
- * Effect executor - interprets effects and calls helpers
781
- * Returns additional events that should be processed (e.g., CONFLICTS_DETECTED after DETECT_CONFLICTS)
782
- */
783
- async function executeEffect(
784
- effect: Effect,
785
- context: {
786
- config: Config
787
- hashTracker: ReturnType<typeof createHashTracker>
788
- installer: Installer | null
789
- fileMetadataCache: FileMetadataCache
790
- userActions: UserActionCoordinator
791
- syncState: SyncState
792
- }
793
- ): Promise<SyncEvent[]> {
794
- const {
795
- config,
796
- hashTracker,
797
- installer,
798
- fileMetadataCache,
799
- userActions,
800
- syncState,
801
- } = context
802
-
803
- switch (effect.type) {
804
- case "INIT_WORKSPACE": {
805
- // Initialize project directory if not already set
806
- if (!config.projectDir) {
807
- const projectName =
808
- config.explicitName ?? effect.projectInfo.projectName
809
- config.projectDir = await findOrCreateProjectDir(
810
- config.projectHash,
811
- projectName,
812
- config.explicitDir
813
- )
814
- config.filesDir = `${config.projectDir}/files`
815
- debug(`Files directory: ${config.filesDir}`)
816
-
817
- // Create files directory
818
- await fs.mkdir(config.filesDir, { recursive: true })
819
- }
820
- return []
821
- }
822
-
823
- case "LOAD_PERSISTED_STATE": {
824
- if (config.projectDir) {
825
- await fileMetadataCache.initialize(config.projectDir)
826
- debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
827
- }
828
- return []
829
- }
830
-
831
- case "LIST_LOCAL_FILES": {
832
- if (!config.filesDir) {
833
- return []
834
- }
835
-
836
- // List all local files and send to plugin
837
- const files = await listFiles(config.filesDir)
838
-
839
- if (syncState.socket) {
840
- await sendMessage(syncState.socket, {
841
- type: "file-list",
842
- files,
843
- })
844
- }
845
-
846
- return []
847
- }
848
-
849
- case "DETECT_CONFLICTS": {
850
- if (!config.filesDir) {
851
- return []
852
- }
853
-
854
- // Use existing helper to detect conflicts
855
- const { conflicts, writes, localOnly, unchanged } = await detectConflicts(
856
- effect.remoteFiles,
857
- config.filesDir,
858
- { persistedState: fileMetadataCache.getPersistedState() }
859
- )
860
-
861
- // Record metadata for unchanged files so watcher add events get skipped
862
- // (chokidar ignoreInitial=false fires late adds that would otherwise re-upload)
863
- for (const file of unchanged) {
864
- fileMetadataCache.recordRemoteWrite(
865
- file.name,
866
- file.content,
867
- file.modifiedAt ?? Date.now()
868
- )
869
- }
870
-
871
- // Return CONFLICTS_DETECTED event to continue the flow
872
- return [
873
- {
874
- type: "CONFLICTS_DETECTED",
875
- conflicts,
876
- safeWrites: writes,
877
- localOnly,
878
- },
879
- ]
880
- }
881
-
882
- case "SEND_MESSAGE": {
883
- if (syncState.socket) {
884
- const sent = await sendMessage(syncState.socket, effect.payload)
885
- if (!sent) {
886
- warn(`Failed to send message: ${effect.payload.type}`)
887
- }
888
- } else {
889
- warn(`No socket available to send: ${effect.payload.type}`)
890
- }
891
- return []
892
- }
893
-
894
- case "WRITE_FILES": {
895
- if (config.filesDir) {
896
- // skipEcho is opt-in: some callers still need side-effects (metadata/logs)
897
- // even when content matches the last hash tracked in-memory.
898
- const filesToWrite =
899
- effect.skipEcho === true
900
- ? filterEchoedFiles(effect.files, hashTracker)
901
- : effect.files
902
-
903
- if (effect.skipEcho && filesToWrite.length !== effect.files.length) {
904
- const skipped = effect.files.length - filesToWrite.length
905
- debug(`Skipped ${pluralize(skipped, "echoed change")}`)
906
- }
907
-
908
- if (filesToWrite.length === 0) {
909
- return []
910
- }
911
-
912
- await writeRemoteFiles(
913
- filesToWrite,
914
- config.filesDir,
915
- hashTracker,
916
- installer ?? undefined
917
- )
918
- for (const file of filesToWrite) {
919
- if (!effect.silent) {
920
- fileDown(file.name)
921
- }
922
- const remoteTimestamp = file.modifiedAt ?? Date.now()
923
- fileMetadataCache.recordRemoteWrite(
924
- file.name,
925
- file.content,
926
- remoteTimestamp
927
- )
928
- }
929
- }
930
- return []
931
- }
932
-
933
- case "DELETE_LOCAL_FILES": {
934
- if (config.filesDir) {
935
- for (const fileName of effect.names) {
936
- await deleteLocalFile(fileName, config.filesDir, hashTracker)
937
- fileDelete(fileName)
938
- fileMetadataCache.recordDelete(fileName)
939
- }
940
- }
941
- return []
942
- }
943
-
944
- case "REQUEST_CONFLICT_DECISIONS": {
945
- await userActions.requestConflictDecisions(
946
- syncState.socket,
947
- effect.conflicts
948
- )
949
-
950
- return []
951
- }
952
-
953
- case "REQUEST_CONFLICT_VERSIONS": {
954
- if (!syncState.socket) {
955
- warn("Cannot request conflict versions without active socket")
956
- return []
957
- }
958
-
959
- const persistedState = fileMetadataCache.getPersistedState()
960
- const versionRequests = effect.conflicts.map((conflict) => {
961
- const persisted = persistedState.get(conflict.fileName)
962
- return {
963
- fileName: conflict.fileName,
964
- lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp,
965
- }
966
- })
967
-
968
- debug(
969
- `Requesting remote version data for ${pluralize(versionRequests.length, "file")}`
970
- )
971
-
972
- await sendMessage(syncState.socket, {
973
- type: "conflict-version-request",
974
- conflicts: versionRequests,
975
- })
976
-
977
- return []
978
- }
979
-
980
- case "REQUEST_DELETE_CONFIRMATION": {
981
- if (syncState.socket) {
982
- // Send delete request to plugin
983
- await sendMessage(syncState.socket, {
984
- type: "file-delete",
985
- fileNames: [effect.fileName],
986
- requireConfirmation: effect.requireConfirmation,
987
- })
988
- }
989
- // Response will come via delete-confirmed or delete-cancelled message
990
- return []
991
- }
992
-
993
- case "UPDATE_FILE_METADATA": {
994
- if (!config.filesDir || !config.projectDir) {
995
- return []
996
- }
997
-
998
- // Read current file content to compute hash
999
- const currentContent = await readFileSafe(
1000
- effect.fileName,
1001
- config.filesDir
1002
- )
1003
-
1004
- if (currentContent !== null) {
1005
- const contentHash = hashFileContent(currentContent)
1006
- fileMetadataCache.recordSyncedSnapshot(
1007
- effect.fileName,
1008
- contentHash,
1009
- effect.remoteModifiedAt
1010
- )
1011
- }
1012
-
1013
- return []
1014
- }
1015
-
1016
- case "SEND_LOCAL_CHANGE": {
1017
- const contentHash = hashFileContent(effect.content)
1018
- const metadata = fileMetadataCache.get(effect.fileName)
1019
-
1020
- // Skip if file matches last confirmed remote content
1021
- if (metadata?.lastSyncedHash === contentHash) {
1022
- debug(
1023
- `Skipping local change for ${effect.fileName}: matches last synced content`
1024
- )
1025
- return []
1026
- }
1027
-
1028
- // Echo prevention: skip if we just wrote this exact content
1029
- if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
1030
- return []
1031
- }
1032
-
1033
- debug(`Local change detected: ${effect.fileName}`)
1034
-
1035
- try {
1036
- // Send change to plugin
1037
- if (syncState.socket) {
1038
- await sendMessage(syncState.socket, {
1039
- type: "file-change",
1040
- fileName: effect.fileName,
1041
- content: effect.content,
1042
- })
1043
- fileUp(effect.fileName)
1044
- }
1045
-
1046
- // Only remember hash after successful send (prevents re-sending on failure)
1047
- hashTracker.remember(effect.fileName, effect.content)
1048
-
1049
- // Trigger type installer
1050
- if (installer) {
1051
- installer.process(effect.fileName, effect.content)
1052
- }
1053
- } catch (err) {
1054
- warn(`Failed to push ${effect.fileName}`)
1055
- }
1056
-
1057
- return []
1058
- }
1059
-
1060
- case "REQUEST_LOCAL_DELETE_DECISION": {
1061
- // Echo prevention: skip if this is a remote-initiated delete
1062
- const shouldSkip = hashTracker.shouldSkipDelete(effect.fileName)
1063
-
1064
- if (shouldSkip) {
1065
- // Clear the delete marker now that we've caught the echo
1066
- hashTracker.clearDelete(effect.fileName)
1067
- return []
1068
- }
1069
-
1070
- try {
1071
- const shouldDelete = await userActions.requestDeleteDecision(
1072
- syncState.socket,
1073
- {
1074
- fileName: effect.fileName,
1075
- requireConfirmation: !config.dangerouslyAutoDelete,
1076
- }
1077
- )
1078
-
1079
- if (shouldDelete) {
1080
- hashTracker.forget(effect.fileName)
1081
- fileMetadataCache.recordDelete(effect.fileName)
1082
-
1083
- if (syncState.socket) {
1084
- await sendMessage(syncState.socket, {
1085
- type: "file-delete",
1086
- fileNames: [effect.fileName],
1087
- })
1088
- }
1089
- }
1090
- } catch (err) {
1091
- console.warn(`Failed to handle deletion for ${effect.fileName}:`, err)
1092
- }
1093
-
1094
- return []
1095
- }
1096
-
1097
- case "PERSIST_STATE": {
1098
- await fileMetadataCache.flush()
1099
- return []
1100
- }
1101
-
1102
- case "SYNC_COMPLETE": {
1103
- const wasDisconnected = wasRecentlyDisconnected()
1104
-
1105
- // Notify plugin that sync is complete
1106
- if (syncState.socket) {
1107
- await sendMessage(syncState.socket, { type: "sync-complete" })
1108
- }
1109
-
1110
- if (wasDisconnected) {
1111
- // Only show reconnect message if we actually showed the disconnect notice
1112
- if (didShowDisconnect()) {
1113
- success(
1114
- `Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
1115
- )
1116
- status("Watching for changes...")
1117
- }
1118
- resetDisconnectState()
1119
- return []
1120
- }
1121
-
1122
- success(
1123
- `Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
1124
- )
1125
- status("Watching for changes...")
1126
- return []
1127
- }
1128
-
1129
- case "LOG": {
1130
- const logFn =
1131
- effect.level === "info" ? info : effect.level === "warn" ? warn : debug
1132
- logFn(effect.message)
1133
- return []
1134
- }
1135
- }
1136
- }
1137
-
1138
- /**
1139
- * Starts the sync controller with the given configuration
1140
- */
1141
- export async function start(config: Config): Promise<void> {
1142
- status("Waiting for Plugin connection...")
1143
-
1144
- const hashTracker = createHashTracker()
1145
- const fileMetadataCache = new FileMetadataCache()
1146
- let installer: Installer | null = null
1147
-
1148
- // State machine state
1149
- let syncState: SyncState = {
1150
- mode: "disconnected",
1151
- socket: null,
1152
- queuedDiffs: [],
1153
- pendingOperations: new Map(),
1154
- nextOperationId: 1,
1155
- }
1156
-
1157
- const userActions = new UserActionCoordinator()
1158
-
1159
- // State Machine Execution Helper
1160
- // Process events through state machine and execute effects recursively
1161
- async function processEvent(event: SyncEvent) {
1162
- const socketState = syncState.socket?.readyState
1163
- debug(
1164
- `[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`
1165
- )
1166
-
1167
- const result = transition(syncState, event)
1168
- syncState = result.state
1169
-
1170
- if (result.effects.length > 0) {
1171
- debug(
1172
- `[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`
1173
- )
1174
- }
1175
-
1176
- // Execute all effects and process any follow-up events
1177
- for (const effect of result.effects) {
1178
- // Check socket state before each effect
1179
- const currentSocketState = syncState.socket?.readyState
1180
- if (currentSocketState !== undefined && currentSocketState !== 1) {
1181
- debug(
1182
- `[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`
1183
- )
1184
- }
1185
-
1186
- const followUpEvents = await executeEffect(effect, {
1187
- config,
1188
- hashTracker,
1189
- installer,
1190
- fileMetadataCache,
1191
- userActions,
1192
- syncState,
1193
- })
1194
-
1195
- // Recursively process follow-up events
1196
- for (const followUpEvent of followUpEvents) {
1197
- await processEvent(followUpEvent)
1198
- }
1199
- }
1200
- }
1201
-
1202
- // WebSocket Connection
1203
- const connection = await initConnection(config.port)
1204
-
1205
- // Handle initial handshake
1206
- connection.on("handshake", async (client: WebSocket, message) => {
1207
- debug(`Received handshake: ${message.projectName} (${message.projectId})`)
1208
-
1209
- // Validate project hash (normalize both to short hash for comparison)
1210
- const expectedShort = shortProjectHash(config.projectHash)
1211
- const receivedShort = shortProjectHash(message.projectId)
1212
- if (receivedShort !== expectedShort) {
1213
- warn(
1214
- `Project ID mismatch: expected ${expectedShort}, got ${receivedShort}`
1215
- )
1216
- client.close()
1217
- return
1218
- }
1219
-
1220
- // Process handshake through state machine
1221
- await processEvent({
1222
- type: "HANDSHAKE",
1223
- socket: client,
1224
- projectInfo: {
1225
- projectId: message.projectId,
1226
- projectName: message.projectName,
1227
- },
1228
- })
1229
-
1230
- // Initialize installer if needed
1231
- if (config.projectDir && !installer) {
1232
- installer = new Installer({ projectDir: config.projectDir })
1233
- await installer.initialize()
1234
- // Start file watcher now that we have a directory
1235
- startWatcher()
1236
- }
1237
-
1238
- // Cancel any pending disconnect message (fast reconnect)
1239
- cancelDisconnectMessage()
1240
-
1241
- // Only show "Connected" on initial connection, not reconnects
1242
- // Reconnect confirmation happens in SYNC_COMPLETE
1243
- const wasDisconnected = wasRecentlyDisconnected()
1244
- if (!wasDisconnected && !didShowDisconnect()) {
1245
- success(`Connected to ${message.projectName}`)
1246
- }
1247
- })
1248
-
1249
- // Message Handler
1250
- async function handleMessage(message: IncomingMessage) {
1251
- // Ensure project is initialized before handling messages
1252
- if (!config.projectDir || !installer) {
1253
- warn("Received message before handshake completed - ignoring")
1254
- return
1255
- }
1256
-
1257
- let event: SyncEvent | null = null
1258
-
1259
- // Map incoming messages to state machine events
1260
- switch (message.type) {
1261
- case "request-files":
1262
- event = { type: "REQUEST_FILES" }
1263
- break
1264
-
1265
- case "file-list": {
1266
- const totalSize = message.files.reduce(
1267
- (sum, f) => sum + (f.content?.length ?? 0),
1268
- 0
1269
- )
1270
- debug(
1271
- `Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`
1272
- )
1273
- event = { type: "FILE_LIST", files: message.files }
1274
- break
1275
- }
1276
-
1277
- case "file-change":
1278
- event = {
1279
- type: "FILE_CHANGE",
1280
- file: {
1281
- name: message.fileName,
1282
- content: message.content,
1283
- // Remote modifiedAt is expensive to compute (requires getVerions API call), so we
1284
- // use local receipt time. Conflict detection uses content hashes, not timestamps.
1285
- modifiedAt: Date.now(),
1286
- },
1287
- fileMeta: fileMetadataCache.get(message.fileName),
1288
- }
1289
- break
1290
-
1291
- case "file-delete": {
1292
- // Remote deletes are always applied immediately (file is already gone from Framer)
1293
- for (const fileName of message.fileNames) {
1294
- await processEvent({
1295
- type: "REMOTE_FILE_DELETE",
1296
- fileName,
1297
- })
1298
- }
1299
- return
1300
- }
1301
-
1302
- case "delete-confirmed": {
1303
- const unmatched: string[] = []
1304
-
1305
- for (const fileName of message.fileNames) {
1306
- const handled = userActions.handleConfirmation(
1307
- `delete:${fileName}`,
1308
- true
1309
- )
1310
-
1311
- if (!handled) {
1312
- unmatched.push(fileName)
1313
- }
1314
- }
1315
-
1316
- for (const fileName of unmatched) {
1317
- await processEvent({ type: "REMOTE_DELETE_CONFIRMED", fileName })
1318
- }
1319
-
1320
- return
1321
- }
1322
-
1323
- case "delete-cancelled": {
1324
- for (const file of message.files) {
1325
- userActions.handleConfirmation(`delete:${file.fileName}`, false)
1326
-
1327
- await processEvent({
1328
- type: "REMOTE_DELETE_CANCELLED",
1329
- fileName: file.fileName,
1330
- content: file.content ?? "",
1331
- })
1332
- }
1333
-
1334
- return
1335
- }
1336
-
1337
- case "file-synced":
1338
- event = {
1339
- type: "FILE_SYNCED",
1340
- fileName: message.fileName,
1341
- remoteModifiedAt: message.remoteModifiedAt,
1342
- }
1343
- break
1344
-
1345
- case "conflicts-resolved":
1346
- event = {
1347
- type: "CONFLICTS_RESOLVED",
1348
- resolution: message.resolution,
1349
- }
1350
- break
1351
-
1352
- case "conflict-version-response":
1353
- event = {
1354
- type: "CONFLICT_VERSION_RESPONSE",
1355
- versions: message.versions,
1356
- }
1357
- break
1358
-
1359
- default:
1360
- warn(`Unhandled message type: ${message.type}`)
1361
- return
1362
- }
1363
-
1364
- if (event) {
1365
- await processEvent(event)
1366
- }
1367
- }
1368
-
1369
- connection.on("message", async (message: IncomingMessage) => {
1370
- try {
1371
- await handleMessage(message)
1372
- } catch (err) {
1373
- error("Error handling message:", err)
1374
- }
1375
- })
1376
-
1377
- connection.on("disconnect", async () => {
1378
- // Schedule disconnect message with delay - if reconnect happens quickly, we skip it
1379
- scheduleDisconnectMessage(() => {
1380
- status("Disconnected, waiting to reconnect...")
1381
- })
1382
- await processEvent({ type: "DISCONNECT" })
1383
- userActions.cleanup()
1384
- })
1385
-
1386
- connection.on("error", (err) => {
1387
- error("Error on WebSocket connection:", err)
1388
- })
1389
-
1390
- // File Watcher Setup
1391
- // Note: Watcher will be initialized after handshake when filesDir is set
1392
-
1393
- let watcher: ReturnType<typeof initWatcher> | null = null
1394
-
1395
- const startWatcher = () => {
1396
- if (!config.filesDir || watcher) return
1397
- watcher = initWatcher(config.filesDir)
1398
-
1399
- watcher.on("change", async (event) => {
1400
- await processEvent({ type: "WATCHER_EVENT", event })
1401
- })
1402
- }
1403
-
1404
- // Lifecycle - no additional startup messages needed (banner shown in index.ts)
1405
-
1406
- // Graceful shutdown
1407
- process.on("SIGINT", async () => {
1408
- console.log() // newline after ^C
1409
- status("Shutting down...")
1410
- if (watcher) {
1411
- await watcher.close()
1412
- }
1413
- connection.close()
1414
- process.exit(0)
1415
- })
1416
- }
1417
-
1418
- // Export for testing
1419
- export { transition }