vite-plugin-mirrorstate 0.2.3 → 0.3.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.
package/dist/index.d.ts CHANGED
@@ -1,9 +1,3 @@
1
1
  import { Plugin } from "vite";
2
- export interface MirrorStatePluginOptions {
3
- path?: string;
4
- filePattern?: string | string[];
5
- watchOptions?: any;
6
- prettyPrint?: boolean;
7
- }
8
- export declare function mirrorStatePlugin(options?: MirrorStatePluginOptions): Plugin;
2
+ export declare function mirrorStatePlugin(): Plugin;
9
3
  export default mirrorStatePlugin;
package/dist/index.js CHANGED
@@ -1,260 +1,185 @@
1
1
  import * as chokidar from "chokidar";
2
2
  import { WebSocketServer } from "ws";
3
3
  import * as fs from "fs";
4
+ import { promises as fsPromises } from "fs";
4
5
  import * as path from "path";
5
6
  import { glob } from "glob";
6
7
  import debug from "debug";
7
8
  const logger = debug("mirrorstate:vite-plugin");
8
- export function mirrorStatePlugin(options = {}) {
9
- const opts = {
10
- path: "/mirrorstate",
11
- filePattern: ["*.mirror.json", "**/*.mirror.json"],
12
- prettyPrint: true,
13
- ...options,
14
- };
9
+ const WS_PATH = "/mirrorstate";
10
+ export function mirrorStatePlugin() {
15
11
  let wss;
16
12
  let watcher;
17
- let recentWrites = new Set(); // Track recent writes to prevent echo
18
- let lastMessageHash = new Map(); // Track last message hash per client to prevent duplicates
19
- let watcherReady = false; // Track if watcher has finished initial scan
13
+ let viteRoot;
14
+ let fileSequences = new Map(); // Track sequence number per file
15
+ let ignoreNextChange = new Map(); // Track our own writes to ignore echo from file watcher
20
16
  return {
21
17
  name: "vite-plugin-mirrorstate",
18
+ configResolved(config) {
19
+ viteRoot = config.root || process.cwd();
20
+ },
22
21
  configureServer(server) {
23
- const wsPath = opts.path;
22
+ const invalidateVirtualModule = () => {
23
+ const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
24
+ if (mod) {
25
+ server.moduleGraph.invalidateModule(mod);
26
+ }
27
+ };
24
28
  wss = new WebSocketServer({ noServer: true });
25
29
  server.httpServer.on("upgrade", (request, socket, head) => {
26
- if (request.url === wsPath) {
30
+ if (request.url === WS_PATH) {
27
31
  wss.handleUpgrade(request, socket, head, (ws) => {
28
32
  wss.emit("connection", ws, request);
29
33
  });
30
34
  }
31
35
  });
36
+ logger(`MirrorState WebSocket listening on ws://localhost:${server.config.server.port || 5173}${WS_PATH}`);
32
37
  const baseDir = server.config.root || process.cwd();
33
- const pattern = Array.isArray(opts.filePattern)
34
- ? opts.filePattern.map((p) => path.join(baseDir, p))
35
- : [path.join(baseDir, opts.filePattern)];
36
- // Find all existing files matching the pattern
37
- const existingFiles = pattern.flatMap((p) => glob.sync(p, {
38
- ignore: "node_modules/**",
39
- }));
40
- logger(`Setting up file watcher for ${existingFiles.length} files: ${JSON.stringify(existingFiles)}`);
41
- // Watch both existing files AND the directory for new files
42
- const watchTargets = [...existingFiles, baseDir];
43
- watcher = chokidar.watch(watchTargets, {
44
- ignored: /node_modules/,
38
+ logger(`Setting up file watcher for ${baseDir}`);
39
+ watcher = chokidar.watch(baseDir, {
40
+ ignored: (path, stats) => {
41
+ // Ignore hidden files, node_modules
42
+ if (path.includes("node_modules") || path.includes("/.")) {
43
+ return true;
44
+ }
45
+ // Only watch .mirror.json files (and directories to traverse)
46
+ if (stats?.isFile() && !path.endsWith(".mirror.json")) {
47
+ return true;
48
+ }
49
+ return false;
50
+ },
45
51
  persistent: true,
46
- ...opts.watchOptions,
52
+ ignoreInitial: true,
47
53
  });
48
54
  watcher.on("add", (filePath) => {
49
- // Only process .mirror.json files added after initial scan
50
- if (!watcherReady || !filePath.endsWith(".mirror.json")) {
51
- return;
52
- }
53
- try {
54
- const relativePath = path.relative(baseDir, filePath);
55
- const content = fs.readFileSync(filePath, "utf8");
56
- const data = JSON.parse(content);
57
- const name = relativePath.replace(/\.mirror\.json$/, "");
58
- // Send new state to all connected clients
59
- wss.clients.forEach((client) => {
60
- if (client.readyState === client.OPEN) {
61
- client.send(JSON.stringify({
62
- type: "initialState",
63
- name,
64
- state: data,
65
- }));
66
- }
67
- });
68
- // Invalidate the virtual module for HMR
69
- const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
70
- if (mod) {
71
- server.moduleGraph.invalidateModule(mod);
72
- }
73
- logger(`New mirror file added: ${name}`);
74
- }
75
- catch (error) {
76
- console.error(`Error reading new mirror file ${filePath}:`, error);
77
- }
55
+ const relativePath = path.relative(baseDir, filePath);
56
+ const name = relativePath.replace(/\.mirror\.json$/, "");
57
+ invalidateVirtualModule();
58
+ logger(`New mirror file added: ${name}`);
78
59
  });
79
60
  watcher.on("unlink", (filePath) => {
80
- // Only process .mirror.json files
81
- if (!filePath.endsWith(".mirror.json")) {
82
- return;
83
- }
84
- try {
85
- const relativePath = path.relative(baseDir, filePath);
86
- const name = relativePath.replace(/\.mirror\.json$/, "");
87
- // Invalidate the virtual module for HMR
88
- const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
89
- if (mod) {
90
- server.moduleGraph.invalidateModule(mod);
91
- }
92
- logger(`Mirror file deleted: ${name}`);
93
- }
94
- catch (error) {
95
- console.error(`Error handling mirror file deletion ${filePath}:`, error);
96
- }
97
- });
98
- watcher.on("ready", () => {
99
- watcherReady = true;
100
- logger("File watcher is ready");
61
+ const relativePath = path.relative(baseDir, filePath);
62
+ const name = relativePath.replace(/\.mirror\.json$/, "");
63
+ invalidateVirtualModule();
64
+ logger(`Mirror file deleted: ${name}`);
101
65
  });
102
- logger(`MirrorState WebSocket listening on ws://localhost:${server.config.server.port || 5173}${wsPath}`);
103
66
  watcher.on("change", (filePath) => {
104
- // Only watch .mirror.json files
105
- if (!filePath.endsWith(".mirror.json")) {
67
+ const relativePath = path.relative(baseDir, filePath);
68
+ const name = relativePath.replace(/\.mirror\.json$/, "");
69
+ // Skip if this is our own write (echo from file watcher)
70
+ if (ignoreNextChange.get(name)) {
71
+ ignoreNextChange.delete(name);
72
+ logger(`Skipping file watcher echo for ${name}`);
106
73
  return;
107
74
  }
75
+ let data;
108
76
  try {
109
- const relativePath = path.relative(baseDir, filePath);
110
- // Skip if this was a recent write from WebSocket to prevent echo
111
- if (recentWrites.has(relativePath)) {
112
- recentWrites.delete(relativePath);
113
- return;
114
- }
115
77
  const content = fs.readFileSync(filePath, "utf8");
116
- const data = JSON.parse(content);
117
- const name = relativePath.replace(/\.mirror\.json$/, "");
118
- // This is an external file change (from editor, etc.)
119
- wss.clients.forEach((client) => {
120
- if (client.readyState === client.OPEN) {
121
- client.send(JSON.stringify({
122
- type: "fileChange",
123
- name,
124
- state: data,
125
- source: "external",
126
- }));
127
- }
128
- });
129
- // Invalidate the virtual module for HMR
130
- const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
131
- if (mod) {
132
- server.moduleGraph.invalidateModule(mod);
133
- }
134
- logger(`Mirror file changed externally: ${name}`);
78
+ data = JSON.parse(content);
135
79
  }
136
80
  catch (error) {
137
81
  console.error(`Error reading mirror file ${filePath}:`, error);
82
+ return;
138
83
  }
139
- });
140
- wss.on("connection", (ws) => {
141
- // Generate unique ID for this connection to prevent echo loops
142
- const clientId = Math.random().toString(36).substring(7);
143
- ws.clientId = clientId;
144
- logger(`Client connected to MirrorState (${clientId})`);
145
- const pattern = Array.isArray(opts.filePattern)
146
- ? opts.filePattern.map((p) => path.join(baseDir, p))
147
- : [path.join(baseDir, opts.filePattern)];
148
- const mirrorFiles = pattern.flatMap((p) => glob.sync(p, {
149
- ignore: "node_modules/**",
150
- }));
151
- mirrorFiles.forEach((filePath) => {
152
- try {
153
- const content = fs.readFileSync(filePath, "utf8");
154
- const data = JSON.parse(content);
155
- const relativePath = path.relative(server.config.root || process.cwd(), filePath);
156
- const name = relativePath.replace(/\.mirror\.json$/, "");
157
- ws.send(JSON.stringify({
158
- type: "initialState",
84
+ const currentSeq = fileSequences.get(name) ?? 0;
85
+ const seq = currentSeq + 1;
86
+ fileSequences.set(name, seq);
87
+ // Broadcast file change with sequence number
88
+ wss.clients.forEach((client) => {
89
+ if (client.readyState === client.OPEN) {
90
+ client.send(JSON.stringify({
91
+ type: "fileChange",
159
92
  name,
160
93
  state: data,
94
+ seq,
161
95
  }));
162
96
  }
163
- catch (error) {
164
- console.error(`Error reading initial state from ${filePath}:`, error);
165
- }
166
97
  });
98
+ invalidateVirtualModule();
99
+ logger(`Mirror file changed externally: ${name} (seq: ${seq})`);
100
+ });
101
+ wss.on("connection", (ws) => {
102
+ // Generate unique ID for this connection for logging purposes
103
+ const clientId = Math.random().toString(36).substring(8);
104
+ ws.clientId = clientId;
105
+ logger(`Client connected to MirrorState [${clientId}]`);
167
106
  ws.on("message", (message) => {
107
+ const messageStr = message.toString();
108
+ let data;
168
109
  try {
169
- const messageStr = message.toString();
170
- const data = JSON.parse(messageStr);
171
- const { name, state } = data;
172
- // Create a hash of the message to detect duplicates
173
- const messageHash = `${name}:${JSON.stringify(state)}`;
174
- const lastHash = lastMessageHash.get(clientId);
175
- // Skip if this is a duplicate message from the same client
176
- if (lastHash === messageHash) {
177
- logger(`Skipping duplicate message from ${clientId} for ${name}`);
178
- return;
179
- }
180
- // Update last message hash for this client
181
- lastMessageHash.set(clientId, messageHash);
182
- const baseDir = server.config.root || process.cwd();
183
- const relativeFilePath = `${name}.mirror.json`;
184
- const filePath = path.join(baseDir, relativeFilePath);
185
- const jsonContent = opts.prettyPrint
186
- ? JSON.stringify(state, null, 2)
187
- : JSON.stringify(state);
188
- // Write state to file
189
- fs.writeFileSync(filePath, jsonContent);
190
- // Mark this as a recent write to prevent file watcher echo
191
- // (only after successful write)
192
- recentWrites.add(relativeFilePath);
193
- // Invalidate the virtual module for HMR
194
- const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
195
- if (mod) {
196
- server.moduleGraph.invalidateModule(mod);
197
- }
198
- // Broadcast to other clients (exclude sender to prevent echo)
199
- wss.clients.forEach((client) => {
200
- if (client !== ws && client.readyState === client.OPEN) {
201
- client.send(JSON.stringify({
202
- type: "fileChange",
203
- name,
204
- state: state,
205
- source: clientId,
206
- }));
207
- }
208
- });
209
- logger(`Updated ${name} with state (from ${clientId}):`, state);
110
+ data = JSON.parse(messageStr);
210
111
  }
211
112
  catch (error) {
212
113
  console.error("Error handling client message:", error);
114
+ return;
213
115
  }
116
+ const { name, state } = data;
117
+ const baseDir = server.config.root || process.cwd();
118
+ const relativeFilePath = `${name}.mirror.json`;
119
+ const filePath = path.join(baseDir, relativeFilePath);
120
+ const jsonContent = JSON.stringify(state, null, 2);
121
+ // Increment sequence number for this file
122
+ const currentSeq = fileSequences.get(name) ?? 0;
123
+ const seq = currentSeq + 1;
124
+ fileSequences.set(name, seq);
125
+ // Set flag to ignore the next file change event (our own write)
126
+ ignoreNextChange.set(name, true);
127
+ // Write state to file
128
+ fs.writeFileSync(filePath, jsonContent);
129
+ // Broadcast to all OTHER clients (skip sender - they already applied optimistically)
130
+ wss.clients.forEach((client) => {
131
+ if (client !== ws && client.readyState === client.OPEN) {
132
+ client.send(JSON.stringify({
133
+ type: "fileChange",
134
+ seq,
135
+ name,
136
+ state,
137
+ }));
138
+ }
139
+ });
140
+ invalidateVirtualModule();
141
+ logger(`Updated ${name} (seq: ${seq}) from [${clientId}]:`, state);
214
142
  });
215
143
  ws.on("close", () => {
216
- // Clean up client data on disconnect
217
- lastMessageHash.delete(clientId);
218
- logger(`Client ${clientId} disconnected`);
144
+ logger(`Client [${clientId}] disconnected`);
219
145
  });
220
146
  });
221
147
  },
222
148
  resolveId(id) {
223
- if (id === "virtual:mirrorstate/config") {
224
- return "\0" + id;
225
- }
226
- if (id === "virtual:mirrorstate/initial-states") {
149
+ if (id === "virtual:mirrorstate/config" ||
150
+ id === "virtual:mirrorstate/initial-states") {
227
151
  return "\0" + id;
228
152
  }
229
153
  },
230
- load(id) {
154
+ async load(id) {
231
155
  if (id === "\0virtual:mirrorstate/config") {
232
- return `export const WS_PATH = "${opts.path}";`;
156
+ return `export const WS_PATH = "${WS_PATH}";`;
233
157
  }
158
+ // During build, read all mirror files and inline them
234
159
  if (id === "\0virtual:mirrorstate/initial-states") {
235
- // During build, read all mirror files and inline them
236
- const baseDir = process.cwd();
237
- const pattern = Array.isArray(opts.filePattern)
238
- ? opts.filePattern.map((p) => path.join(baseDir, p))
239
- : [path.join(baseDir, opts.filePattern)];
240
- const mirrorFiles = pattern.flatMap((p) => glob.sync(p, {
160
+ const baseDir = viteRoot || process.cwd();
161
+ const mirrorFiles = glob.sync("**/*.mirror.json", {
162
+ cwd: baseDir,
241
163
  ignore: "node_modules/**",
242
- }));
243
- const states = {};
244
- mirrorFiles.forEach((filePath) => {
164
+ });
165
+ const filePromises = mirrorFiles.map(async (relativePath) => {
166
+ const absolutePath = path.join(baseDir, relativePath);
167
+ const name = relativePath.replace(/\.mirror\.json$/, "");
168
+ let data;
245
169
  try {
246
- const content = fs.readFileSync(filePath, "utf8");
247
- const data = JSON.parse(content);
248
- const relativePath = path.relative(process.cwd(), filePath);
249
- const name = relativePath.replace(/\.mirror\.json$/, "");
250
- states[name] = data;
251
- logger(`Inlined initial state for ${name}`);
170
+ const content = await fsPromises.readFile(absolutePath, "utf8");
171
+ data = JSON.parse(content);
252
172
  }
253
173
  catch (error) {
254
- console.error(`Error reading initial state from ${filePath}:`, error);
174
+ console.error(`Error reading initial state from ${relativePath}:`, error);
175
+ return null;
255
176
  }
177
+ logger(`Inlined initial state for ${name}`);
178
+ return [name, data];
256
179
  });
257
- return `export const INITIAL_STATES = ${JSON.stringify(states, null, 2)};`;
180
+ const results = await Promise.all(filePromises);
181
+ const states = Object.fromEntries(results.filter((x) => x != null));
182
+ return `export const INITIAL_STATES = ${JSON.stringify(states)};`;
258
183
  }
259
184
  },
260
185
  closeBundle() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-mirrorstate",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
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",