vite-plugin-mirrorstate 0.3.0 → 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,267 +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;
13
+ let viteRoot;
17
14
  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
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
15
+ let ignoreNextChange = new Map(); // Track our own writes to ignore echo from file watcher
22
16
  return {
23
17
  name: "vite-plugin-mirrorstate",
24
18
  configResolved(config) {
25
- // Capture vite root for use in other hooks
26
19
  viteRoot = config.root || process.cwd();
27
20
  },
28
21
  configureServer(server) {
29
- 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
+ };
30
28
  wss = new WebSocketServer({ noServer: true });
31
29
  server.httpServer.on("upgrade", (request, socket, head) => {
32
- if (request.url === wsPath) {
30
+ if (request.url === WS_PATH) {
33
31
  wss.handleUpgrade(request, socket, head, (ws) => {
34
32
  wss.emit("connection", ws, request);
35
33
  });
36
34
  }
37
35
  });
36
+ logger(`MirrorState WebSocket listening on ws://localhost:${server.config.server.port || 5173}${WS_PATH}`);
38
37
  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, {
50
- 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
+ },
51
51
  persistent: true,
52
- ...opts.watchOptions,
52
+ ignoreInitial: true,
53
53
  });
54
54
  watcher.on("add", (filePath) => {
55
- // Only process .mirror.json files added after initial scan
56
- if (!watcherReady || !filePath.endsWith(".mirror.json")) {
55
+ const relativePath = path.relative(baseDir, filePath);
56
+ const name = relativePath.replace(/\.mirror\.json$/, "");
57
+ invalidateVirtualModule();
58
+ logger(`New mirror file added: ${name}`);
59
+ });
60
+ watcher.on("unlink", (filePath) => {
61
+ const relativePath = path.relative(baseDir, filePath);
62
+ const name = relativePath.replace(/\.mirror\.json$/, "");
63
+ invalidateVirtualModule();
64
+ logger(`Mirror file deleted: ${name}`);
65
+ });
66
+ watcher.on("change", (filePath) => {
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}`);
57
73
  return;
58
74
  }
75
+ let data;
59
76
  try {
60
- const relativePath = path.relative(baseDir, filePath);
61
77
  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}`);
78
+ data = JSON.parse(content);
80
79
  }
81
80
  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")) {
81
+ console.error(`Error reading mirror file ${filePath}:`, error);
88
82
  return;
89
83
  }
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);
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",
92
+ name,
93
+ state: data,
94
+ seq,
95
+ }));
97
96
  }
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");
97
+ });
98
+ invalidateVirtualModule();
99
+ logger(`Mirror file changed externally: ${name} (seq: ${seq})`);
107
100
  });
108
- logger(`MirrorState WebSocket listening on ws://localhost:${server.config.server.port || 5173}${wsPath}`);
109
- watcher.on("change", (filePath) => {
110
- // Only watch .mirror.json files
111
- if (!filePath.endsWith(".mirror.json")) {
112
- return;
113
- }
114
- try {
115
- const relativePath = path.relative(baseDir, filePath);
116
- const content = fs.readFileSync(filePath, "utf8");
117
- const data = JSON.parse(content);
118
- const name = relativePath.replace(/\.mirror\.json$/, "");
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}`);
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}]`);
106
+ ws.on("message", (message) => {
107
+ const messageStr = message.toString();
108
+ let data;
109
+ try {
110
+ data = JSON.parse(messageStr);
111
+ }
112
+ catch (error) {
113
+ console.error("Error handling client message:", error);
125
114
  return;
126
115
  }
127
- // This is an external change - increment sequence number
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
128
122
  const currentSeq = fileSequences.get(name) ?? 0;
129
123
  const seq = currentSeq + 1;
130
124
  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
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)
134
130
  wss.clients.forEach((client) => {
135
- if (client.readyState === client.OPEN) {
131
+ if (client !== ws && client.readyState === client.OPEN) {
136
132
  client.send(JSON.stringify({
137
133
  type: "fileChange",
138
- name,
139
- state: data,
140
134
  seq,
141
- source: "external",
135
+ name,
136
+ state,
142
137
  }));
143
138
  }
144
139
  });
145
- // Invalidate the virtual module for HMR
146
- const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
147
- if (mod) {
148
- server.moduleGraph.invalidateModule(mod);
149
- }
150
- logger(`Mirror file changed externally: ${name} (seq: ${seq})`);
151
- }
152
- catch (error) {
153
- console.error(`Error reading mirror file ${filePath}:`, error);
154
- }
155
- });
156
- wss.on("connection", (ws) => {
157
- // Generate unique ID for this connection to prevent echo loops
158
- const clientId = Math.random().toString(36).substring(7);
159
- ws.clientId = clientId;
160
- logger(`Client connected to MirrorState (${clientId})`);
161
- // Send clientId to the client
162
- const connectedMessage = JSON.stringify({
163
- type: "connected",
164
- clientId,
165
- });
166
- logger(`Sending connected message: ${connectedMessage}`);
167
- ws.send(connectedMessage);
168
- ws.on("message", (message) => {
169
- try {
170
- const messageStr = message.toString();
171
- const data = JSON.parse(messageStr);
172
- const { clientId: msgClientId, name, state } = data;
173
- // Create a hash of the message to detect duplicates
174
- const messageHash = `${name}:${JSON.stringify(state)}`;
175
- const lastHash = lastMessageHash.get(clientId);
176
- // Skip if this is a duplicate message from the same client
177
- if (lastHash === messageHash) {
178
- logger(`Skipping duplicate message from ${clientId} for ${name}`);
179
- return;
180
- }
181
- // Update last message hash for this client
182
- lastMessageHash.set(clientId, messageHash);
183
- const baseDir = server.config.root || process.cwd();
184
- const relativeFilePath = `${name}.mirror.json`;
185
- const filePath = path.join(baseDir, relativeFilePath);
186
- const jsonContent = opts.prettyPrint
187
- ? JSON.stringify(state, null, 2)
188
- : JSON.stringify(state);
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));
195
- // Write state to file
196
- fs.writeFileSync(filePath, jsonContent);
197
- // Invalidate the virtual module for HMR
198
- const mod = server.moduleGraph.getModuleById("\0virtual:mirrorstate/initial-states");
199
- if (mod) {
200
- server.moduleGraph.invalidateModule(mod);
201
- }
202
- // Broadcast to all OTHER clients (skip sender - they already applied optimistically)
203
- wss.clients.forEach((client) => {
204
- if (client !== ws && client.readyState === client.OPEN) {
205
- client.send(JSON.stringify({
206
- type: "fileChange",
207
- clientId: msgClientId,
208
- seq,
209
- name,
210
- state: state,
211
- }));
212
- }
213
- });
214
- logger(`Updated ${name} (seq: ${seq}) from ${clientId}:`, state);
215
- }
216
- catch (error) {
217
- console.error("Error handling client message:", error);
218
- }
140
+ invalidateVirtualModule();
141
+ logger(`Updated ${name} (seq: ${seq}) from [${clientId}]:`, state);
219
142
  });
220
143
  ws.on("close", () => {
221
- // Clean up client data on disconnect
222
- lastMessageHash.delete(clientId);
223
- logger(`Client ${clientId} disconnected`);
144
+ logger(`Client [${clientId}] disconnected`);
224
145
  });
225
146
  });
226
147
  },
227
148
  resolveId(id) {
228
- if (id === "virtual:mirrorstate/config") {
229
- return "\0" + id;
230
- }
231
- if (id === "virtual:mirrorstate/initial-states") {
149
+ if (id === "virtual:mirrorstate/config" ||
150
+ id === "virtual:mirrorstate/initial-states") {
232
151
  return "\0" + id;
233
152
  }
234
153
  },
235
- load(id) {
154
+ async load(id) {
236
155
  if (id === "\0virtual:mirrorstate/config") {
237
- return `export const WS_PATH = "${opts.path}";`;
156
+ return `export const WS_PATH = "${WS_PATH}";`;
238
157
  }
158
+ // During build, read all mirror files and inline them
239
159
  if (id === "\0virtual:mirrorstate/initial-states") {
240
- // During build, read all mirror files and inline them
241
- // Use vite root instead of process.cwd() to handle monorepos correctly
242
160
  const baseDir = viteRoot || process.cwd();
243
- const pattern = Array.isArray(opts.filePattern)
244
- ? opts.filePattern.map((p) => path.join(baseDir, p))
245
- : [path.join(baseDir, opts.filePattern)];
246
- const mirrorFiles = pattern.flatMap((p) => glob.sync(p, {
161
+ const mirrorFiles = glob.sync("**/*.mirror.json", {
162
+ cwd: baseDir,
247
163
  ignore: "node_modules/**",
248
- }));
249
- const states = {};
250
- 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;
251
169
  try {
252
- const content = fs.readFileSync(filePath, "utf8");
253
- const data = JSON.parse(content);
254
- // Use baseDir (vite root) for relative path calculation
255
- const relativePath = path.relative(baseDir, filePath);
256
- const name = relativePath.replace(/\.mirror\.json$/, "");
257
- states[name] = data;
258
- logger(`Inlined initial state for ${name}`);
170
+ const content = await fsPromises.readFile(absolutePath, "utf8");
171
+ data = JSON.parse(content);
259
172
  }
260
173
  catch (error) {
261
- console.error(`Error reading initial state from ${filePath}:`, error);
174
+ console.error(`Error reading initial state from ${relativePath}:`, error);
175
+ return null;
262
176
  }
177
+ logger(`Inlined initial state for ${name}`);
178
+ return [name, data];
263
179
  });
264
- 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)};`;
265
183
  }
266
184
  },
267
185
  closeBundle() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-mirrorstate",
3
- "version": "0.3.0",
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",