vite-plugin-mirrorstate 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +129 -46
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,16 +8,23 @@ const logger = debug("mirrorstate:vite-plugin");
8
8
  export function mirrorStatePlugin(options = {}) {
9
9
  const opts = {
10
10
  path: "/mirrorstate",
11
- filePattern: "**/*.mirror.json",
11
+ filePattern: ["*.mirror.json", "**/*.mirror.json"],
12
12
  prettyPrint: true,
13
13
  ...options,
14
14
  };
15
15
  let wss;
16
16
  let watcher;
17
- let recentWrites = new Set(); // Track recent writes to prevent echo
17
+ let fileSequences = new Map(); // Track sequence number per file
18
+ let lastWrittenState = new Map(); // Track last written state hash per file to detect external changes
18
19
  let lastMessageHash = new Map(); // Track last message hash per client to prevent duplicates
20
+ let watcherReady = false; // Track if watcher has finished initial scan
21
+ let viteRoot; // Captured vite root directory
19
22
  return {
20
23
  name: "vite-plugin-mirrorstate",
24
+ configResolved(config) {
25
+ // Capture vite root for use in other hooks
26
+ viteRoot = config.root || process.cwd();
27
+ },
21
28
  configureServer(server) {
22
29
  const wsPath = opts.path;
23
30
  wss = new WebSocketServer({ noServer: true });
@@ -28,40 +35,119 @@ export function mirrorStatePlugin(options = {}) {
28
35
  });
29
36
  }
30
37
  });
31
- watcher = chokidar.watch(opts.filePattern, {
38
+ const baseDir = server.config.root || process.cwd();
39
+ const pattern = Array.isArray(opts.filePattern)
40
+ ? opts.filePattern.map((p) => path.join(baseDir, p))
41
+ : [path.join(baseDir, opts.filePattern)];
42
+ // Find all existing files matching the pattern
43
+ const existingFiles = pattern.flatMap((p) => glob.sync(p, {
44
+ ignore: "node_modules/**",
45
+ }));
46
+ logger(`Setting up file watcher for ${existingFiles.length} files: ${JSON.stringify(existingFiles)}`);
47
+ // Watch both existing files AND the directory for new files
48
+ const watchTargets = [...existingFiles, baseDir];
49
+ watcher = chokidar.watch(watchTargets, {
32
50
  ignored: /node_modules/,
33
51
  persistent: true,
34
52
  ...opts.watchOptions,
35
53
  });
54
+ watcher.on("add", (filePath) => {
55
+ // Only process .mirror.json files added after initial scan
56
+ if (!watcherReady || !filePath.endsWith(".mirror.json")) {
57
+ return;
58
+ }
59
+ try {
60
+ const relativePath = path.relative(baseDir, filePath);
61
+ const content = fs.readFileSync(filePath, "utf8");
62
+ const data = JSON.parse(content);
63
+ const name = relativePath.replace(/\.mirror\.json$/, "");
64
+ // Send new state to all connected clients
65
+ wss.clients.forEach((client) => {
66
+ if (client.readyState === client.OPEN) {
67
+ client.send(JSON.stringify({
68
+ type: "initialState",
69
+ name,
70
+ state: data,
71
+ }));
72
+ }
73
+ });
74
+ // Invalidate the virtual module for HMR
75
+ const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
76
+ if (mod) {
77
+ server.moduleGraph.invalidateModule(mod);
78
+ }
79
+ logger(`New mirror file added: ${name}`);
80
+ }
81
+ catch (error) {
82
+ console.error(`Error reading new mirror file ${filePath}:`, error);
83
+ }
84
+ });
85
+ watcher.on("unlink", (filePath) => {
86
+ // Only process .mirror.json files
87
+ if (!filePath.endsWith(".mirror.json")) {
88
+ return;
89
+ }
90
+ try {
91
+ const relativePath = path.relative(baseDir, filePath);
92
+ const name = relativePath.replace(/\.mirror\.json$/, "");
93
+ // Invalidate the virtual module for HMR
94
+ const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
95
+ if (mod) {
96
+ server.moduleGraph.invalidateModule(mod);
97
+ }
98
+ logger(`Mirror file deleted: ${name}`);
99
+ }
100
+ catch (error) {
101
+ console.error(`Error handling mirror file deletion ${filePath}:`, error);
102
+ }
103
+ });
104
+ watcher.on("ready", () => {
105
+ watcherReady = true;
106
+ logger("File watcher is ready");
107
+ });
36
108
  logger(`MirrorState WebSocket listening on ws://localhost:${server.config.server.port || 5173}${wsPath}`);
37
109
  watcher.on("change", (filePath) => {
110
+ // Only watch .mirror.json files
111
+ if (!filePath.endsWith(".mirror.json")) {
112
+ return;
113
+ }
38
114
  try {
39
- // Skip if this was a recent write from WebSocket to prevent echo
40
- if (recentWrites.has(filePath)) {
41
- recentWrites.delete(filePath);
42
- return;
43
- }
115
+ const relativePath = path.relative(baseDir, filePath);
44
116
  const content = fs.readFileSync(filePath, "utf8");
45
117
  const data = JSON.parse(content);
46
- const relativePath = path.relative(server.config.root || process.cwd(), filePath);
47
118
  const name = relativePath.replace(/\.mirror\.json$/, "");
48
- // This is an external file change (from editor, etc.)
119
+ // Create hash of the state to detect if this is our own write
120
+ const stateHash = JSON.stringify(data);
121
+ const lastHash = lastWrittenState.get(name);
122
+ // Skip if this matches what we just wrote (echo from our own write)
123
+ if (lastHash === stateHash) {
124
+ logger(`Skipping file watcher echo for ${name}`);
125
+ return;
126
+ }
127
+ // This is an external change - increment sequence number
128
+ const currentSeq = fileSequences.get(name) ?? 0;
129
+ const seq = currentSeq + 1;
130
+ fileSequences.set(name, seq);
131
+ // Update our record of the last written state
132
+ lastWrittenState.set(name, stateHash);
133
+ // Broadcast file change with sequence number
49
134
  wss.clients.forEach((client) => {
50
135
  if (client.readyState === client.OPEN) {
51
136
  client.send(JSON.stringify({
52
137
  type: "fileChange",
53
138
  name,
54
139
  state: data,
140
+ seq,
55
141
  source: "external",
56
142
  }));
57
143
  }
58
144
  });
59
145
  // Invalidate the virtual module for HMR
60
- const mod = server.moduleGraph.getModuleById("virtual:mirrorstate/initial-states");
146
+ const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
61
147
  if (mod) {
62
148
  server.moduleGraph.invalidateModule(mod);
63
149
  }
64
- logger(`Mirror file changed externally: ${name}`);
150
+ logger(`Mirror file changed externally: ${name} (seq: ${seq})`);
65
151
  }
66
152
  catch (error) {
67
153
  console.error(`Error reading mirror file ${filePath}:`, error);
@@ -72,31 +158,18 @@ export function mirrorStatePlugin(options = {}) {
72
158
  const clientId = Math.random().toString(36).substring(7);
73
159
  ws.clientId = clientId;
74
160
  logger(`Client connected to MirrorState (${clientId})`);
75
- const pattern = Array.isArray(opts.filePattern)
76
- ? opts.filePattern
77
- : [opts.filePattern];
78
- const mirrorFiles = pattern.flatMap((p) => glob.sync(p, { ignore: "node_modules/**" }));
79
- mirrorFiles.forEach((filePath) => {
80
- try {
81
- const content = fs.readFileSync(filePath, "utf8");
82
- const data = JSON.parse(content);
83
- const relativePath = path.relative(server.config.root || process.cwd(), filePath);
84
- const name = relativePath.replace(/\.mirror\.json$/, "");
85
- ws.send(JSON.stringify({
86
- type: "initialState",
87
- name,
88
- state: data,
89
- }));
90
- }
91
- catch (error) {
92
- console.error(`Error reading initial state from ${filePath}:`, error);
93
- }
161
+ // Send clientId to the client
162
+ const connectedMessage = JSON.stringify({
163
+ type: "connected",
164
+ clientId,
94
165
  });
166
+ logger(`Sending connected message: ${connectedMessage}`);
167
+ ws.send(connectedMessage);
95
168
  ws.on("message", (message) => {
96
169
  try {
97
170
  const messageStr = message.toString();
98
171
  const data = JSON.parse(messageStr);
99
- const { name, state } = data;
172
+ const { clientId: msgClientId, name, state } = data;
100
173
  // Create a hash of the message to detect duplicates
101
174
  const messageHash = `${name}:${JSON.stringify(state)}`;
102
175
  const lastHash = lastMessageHash.get(clientId);
@@ -107,33 +180,38 @@ export function mirrorStatePlugin(options = {}) {
107
180
  }
108
181
  // Update last message hash for this client
109
182
  lastMessageHash.set(clientId, messageHash);
110
- const filePath = `${name}.mirror.json`;
183
+ const baseDir = server.config.root || process.cwd();
184
+ const relativeFilePath = `${name}.mirror.json`;
185
+ const filePath = path.join(baseDir, relativeFilePath);
111
186
  const jsonContent = opts.prettyPrint
112
187
  ? JSON.stringify(state, null, 2)
113
188
  : JSON.stringify(state);
114
- // Mark this as a recent write to prevent file watcher echo
115
- recentWrites.add(filePath);
189
+ // Increment sequence number for this file BEFORE writing
190
+ const currentSeq = fileSequences.get(name) ?? 0;
191
+ const seq = currentSeq + 1;
192
+ fileSequences.set(name, seq);
193
+ // Record state hash to detect our own write in file watcher
194
+ lastWrittenState.set(name, JSON.stringify(state));
116
195
  // Write state to file
117
196
  fs.writeFileSync(filePath, jsonContent);
118
197
  // Invalidate the virtual module for HMR
119
- const mod = server.moduleGraph.getModuleById("virtual:mirrorstate/initial-states");
198
+ const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
120
199
  if (mod) {
121
200
  server.moduleGraph.invalidateModule(mod);
122
201
  }
123
- // Broadcast to other clients (exclude sender to prevent echo)
202
+ // Broadcast to all OTHER clients (skip sender - they already applied optimistically)
124
203
  wss.clients.forEach((client) => {
125
204
  if (client !== ws && client.readyState === client.OPEN) {
126
- const relativePath = path.relative(server.config.root || process.cwd(), filePath);
127
- const fileName = relativePath.replace(/\.mirror\.json$/, "");
128
205
  client.send(JSON.stringify({
129
206
  type: "fileChange",
130
- name: fileName,
207
+ clientId: msgClientId,
208
+ seq,
209
+ name,
131
210
  state: state,
132
- source: clientId,
133
211
  }));
134
212
  }
135
213
  });
136
- logger(`Updated ${name} with state (from ${clientId}):`, state);
214
+ logger(`Updated ${name} (seq: ${seq}) from ${clientId}:`, state);
137
215
  }
138
216
  catch (error) {
139
217
  console.error("Error handling client message:", error);
@@ -160,16 +238,21 @@ export function mirrorStatePlugin(options = {}) {
160
238
  }
161
239
  if (id === "\0virtual:mirrorstate/initial-states") {
162
240
  // During build, read all mirror files and inline them
241
+ // Use vite root instead of process.cwd() to handle monorepos correctly
242
+ const baseDir = viteRoot || process.cwd();
163
243
  const pattern = Array.isArray(opts.filePattern)
164
- ? opts.filePattern
165
- : [opts.filePattern];
166
- const mirrorFiles = pattern.flatMap((p) => glob.sync(p, { ignore: "node_modules/**" }));
244
+ ? opts.filePattern.map((p) => path.join(baseDir, p))
245
+ : [path.join(baseDir, opts.filePattern)];
246
+ const mirrorFiles = pattern.flatMap((p) => glob.sync(p, {
247
+ ignore: "node_modules/**",
248
+ }));
167
249
  const states = {};
168
250
  mirrorFiles.forEach((filePath) => {
169
251
  try {
170
252
  const content = fs.readFileSync(filePath, "utf8");
171
253
  const data = JSON.parse(content);
172
- const relativePath = path.relative(process.cwd(), filePath);
254
+ // Use baseDir (vite root) for relative path calculation
255
+ const relativePath = path.relative(baseDir, filePath);
173
256
  const name = relativePath.replace(/\.mirror\.json$/, "");
174
257
  states[name] = data;
175
258
  logger(`Inlined initial state for ${name}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-mirrorstate",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Vite plugin for bidirectional state synchronization through *.mirror.json files",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",