mrmd-sync 0.2.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.
- package/README.md +925 -0
- package/bin/cli.js +193 -0
- package/package.json +49 -0
- package/src/index.d.ts +230 -0
- package/src/index.js +1127 -0
- package/src/ws-utils.js +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
# mrmd-sync
|
|
2
|
+
|
|
3
|
+
Real-time sync server for collaborative markdown editing. Connects browsers to your filesystem with bidirectional sync via Yjs CRDTs.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Browser ←──WebSocket──→ mrmd-sync ←──→ ./docs/*.md
|
|
7
|
+
│ │
|
|
8
|
+
└── Real-time collab ────┘
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Start syncing a directory
|
|
15
|
+
npx mrmd-sync ./docs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That's it. Open `ws://localhost:4444/readme` in mrmd-editor and start editing. Changes sync to `./docs/readme.md`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Table of Contents
|
|
23
|
+
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [CLI Usage](#cli-usage)
|
|
26
|
+
- [Programmatic Usage](#programmatic-usage)
|
|
27
|
+
- [Examples](#examples)
|
|
28
|
+
- [Basic File Sync](#basic-file-sync)
|
|
29
|
+
- [With Authentication](#with-authentication)
|
|
30
|
+
- [Express Integration](#express-integration)
|
|
31
|
+
- [Multiple Directories](#multiple-directories)
|
|
32
|
+
- [Configuration](#configuration)
|
|
33
|
+
- [HTTP Endpoints](#http-endpoints)
|
|
34
|
+
- [API Reference](#api-reference)
|
|
35
|
+
- [How It Works](#how-it-works)
|
|
36
|
+
- [Security](#security)
|
|
37
|
+
- [Operational Notes](#operational-notes)
|
|
38
|
+
- [Troubleshooting](#troubleshooting)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Global install
|
|
46
|
+
npm install -g mrmd-sync
|
|
47
|
+
|
|
48
|
+
# Or use npx (no install)
|
|
49
|
+
npx mrmd-sync ./docs
|
|
50
|
+
|
|
51
|
+
# Or add to project
|
|
52
|
+
npm install mrmd-sync
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## CLI Usage
|
|
58
|
+
|
|
59
|
+
### Basic
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Sync current directory's docs folder
|
|
63
|
+
mrmd-sync ./docs
|
|
64
|
+
|
|
65
|
+
# Custom port
|
|
66
|
+
mrmd-sync --port 8080 ./docs
|
|
67
|
+
|
|
68
|
+
# Short flag
|
|
69
|
+
mrmd-sync -p 8080 ./docs
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### All Options
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
mrmd-sync [options] [directory]
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--port, -p <port> WebSocket port (default: 4444)
|
|
79
|
+
--debounce <ms> Write debounce delay (default: 1000)
|
|
80
|
+
--max-connections <n> Max total connections (default: 100)
|
|
81
|
+
--max-per-doc <n> Max connections per document (default: 50)
|
|
82
|
+
--max-file-size <bytes> Max file size to sync (default: 10485760)
|
|
83
|
+
--ping-interval <ms> Heartbeat interval (default: 30000)
|
|
84
|
+
--cleanup-delay <ms> Doc cleanup after disconnect (default: 60000)
|
|
85
|
+
--i-know-what-i-am-doing Allow syncing system paths (/, /etc, /home)
|
|
86
|
+
--help, -h Show help
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Example: Production Settings
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
mrmd-sync \
|
|
93
|
+
--port 443 \
|
|
94
|
+
--max-connections 500 \
|
|
95
|
+
--max-per-doc 100 \
|
|
96
|
+
--debounce 2000 \
|
|
97
|
+
./production-docs
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Programmatic Usage
|
|
103
|
+
|
|
104
|
+
```javascript
|
|
105
|
+
import { createServer } from 'mrmd-sync';
|
|
106
|
+
|
|
107
|
+
const server = createServer({
|
|
108
|
+
dir: './docs',
|
|
109
|
+
port: 4444,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Server is now running
|
|
113
|
+
console.log('Sync server started');
|
|
114
|
+
|
|
115
|
+
// Graceful shutdown
|
|
116
|
+
process.on('SIGINT', () => {
|
|
117
|
+
server.close();
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Examples
|
|
124
|
+
|
|
125
|
+
### Basic File Sync
|
|
126
|
+
|
|
127
|
+
The simplest setup - sync a folder:
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
import { createServer } from 'mrmd-sync';
|
|
131
|
+
|
|
132
|
+
// Start server
|
|
133
|
+
const server = createServer({
|
|
134
|
+
dir: './my-notes',
|
|
135
|
+
port: 4444,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// That's it! Connect from browser:
|
|
139
|
+
// ws://localhost:4444/meeting-notes → ./my-notes/meeting-notes.md
|
|
140
|
+
// ws://localhost:4444/todo → ./my-notes/todo.md
|
|
141
|
+
// ws://localhost:4444/journal/2024 → ./my-notes/journal/2024.md
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Browser side (with mrmd-editor):**
|
|
145
|
+
|
|
146
|
+
```javascript
|
|
147
|
+
import mrmd from 'mrmd-editor';
|
|
148
|
+
|
|
149
|
+
// Connect to sync server
|
|
150
|
+
const drive = mrmd.drive('ws://localhost:4444');
|
|
151
|
+
|
|
152
|
+
// Open a document
|
|
153
|
+
const editor = drive.open('meeting-notes', '#editor');
|
|
154
|
+
|
|
155
|
+
// Everything typed syncs automatically!
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### With Authentication
|
|
161
|
+
|
|
162
|
+
Protect documents with custom auth:
|
|
163
|
+
|
|
164
|
+
```javascript
|
|
165
|
+
import { createServer } from 'mrmd-sync';
|
|
166
|
+
import jwt from 'jsonwebtoken';
|
|
167
|
+
|
|
168
|
+
const server = createServer({
|
|
169
|
+
dir: './docs',
|
|
170
|
+
port: 4444,
|
|
171
|
+
|
|
172
|
+
// Auth receives the HTTP request and document name
|
|
173
|
+
auth: async (req, docName) => {
|
|
174
|
+
// Get token from query string: ws://localhost:4444/doc?token=xxx
|
|
175
|
+
const url = new URL(req.url, 'http://localhost');
|
|
176
|
+
const token = url.searchParams.get('token');
|
|
177
|
+
|
|
178
|
+
if (!token) {
|
|
179
|
+
return false; // Reject connection
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
|
184
|
+
|
|
185
|
+
// Check document access
|
|
186
|
+
if (docName.startsWith('private/') && !payload.isAdmin) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return true; // Allow connection
|
|
191
|
+
} catch {
|
|
192
|
+
return false; // Invalid token
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Browser side:**
|
|
199
|
+
|
|
200
|
+
```javascript
|
|
201
|
+
const token = await getAuthToken();
|
|
202
|
+
const drive = mrmd.drive(`ws://localhost:4444?token=${token}`);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
### Express Integration
|
|
208
|
+
|
|
209
|
+
Run alongside an Express server:
|
|
210
|
+
|
|
211
|
+
```javascript
|
|
212
|
+
import express from 'express';
|
|
213
|
+
import { createServer } from 'mrmd-sync';
|
|
214
|
+
|
|
215
|
+
const app = express();
|
|
216
|
+
|
|
217
|
+
// Your REST API
|
|
218
|
+
app.get('/api/documents', (req, res) => {
|
|
219
|
+
res.json({ documents: ['readme', 'notes', 'todo'] });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Start Express
|
|
223
|
+
const httpServer = app.listen(3000);
|
|
224
|
+
|
|
225
|
+
// Start sync server on different port
|
|
226
|
+
const syncServer = createServer({
|
|
227
|
+
dir: './docs',
|
|
228
|
+
port: 4444,
|
|
229
|
+
auth: async (req, docName) => {
|
|
230
|
+
// Validate session cookie
|
|
231
|
+
const sessionId = parseCookie(req.headers.cookie)?.session;
|
|
232
|
+
return await validateSession(sessionId);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
console.log('API: http://localhost:3000');
|
|
237
|
+
console.log('Sync: ws://localhost:4444');
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
### Multiple Directories
|
|
243
|
+
|
|
244
|
+
Run multiple sync servers for different purposes:
|
|
245
|
+
|
|
246
|
+
```javascript
|
|
247
|
+
import { createServer } from 'mrmd-sync';
|
|
248
|
+
|
|
249
|
+
// Public docs - no auth, read-heavy
|
|
250
|
+
const publicDocs = createServer({
|
|
251
|
+
dir: './public-docs',
|
|
252
|
+
port: 4444,
|
|
253
|
+
maxConnections: 200,
|
|
254
|
+
maxConnectionsPerDoc: 100,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Private workspace - auth required, smaller limits
|
|
258
|
+
const privateDocs = createServer({
|
|
259
|
+
dir: './private-docs',
|
|
260
|
+
port: 4445,
|
|
261
|
+
maxConnections: 20,
|
|
262
|
+
maxConnectionsPerDoc: 5,
|
|
263
|
+
auth: async (req, docName) => {
|
|
264
|
+
return checkAuth(req);
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Team collaboration - balanced settings
|
|
269
|
+
const teamDocs = createServer({
|
|
270
|
+
dir: './team-docs',
|
|
271
|
+
port: 4446,
|
|
272
|
+
maxConnections: 50,
|
|
273
|
+
maxConnectionsPerDoc: 20,
|
|
274
|
+
debounceMs: 500, // Faster saves for active collab
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
### Monitoring & Stats
|
|
281
|
+
|
|
282
|
+
Get real-time statistics:
|
|
283
|
+
|
|
284
|
+
```javascript
|
|
285
|
+
import { createServer } from 'mrmd-sync';
|
|
286
|
+
|
|
287
|
+
const server = createServer({ dir: './docs', port: 4444 });
|
|
288
|
+
|
|
289
|
+
// Check stats periodically
|
|
290
|
+
setInterval(() => {
|
|
291
|
+
const stats = server.getStats();
|
|
292
|
+
console.log(`Connections: ${stats.totalConnections}`);
|
|
293
|
+
console.log(`Active docs: ${stats.totalDocs}`);
|
|
294
|
+
|
|
295
|
+
// Per-document breakdown
|
|
296
|
+
stats.docs.forEach(doc => {
|
|
297
|
+
console.log(` ${doc.name}: ${doc.connections} clients`);
|
|
298
|
+
});
|
|
299
|
+
}, 10000);
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Example output:**
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
Connections: 12
|
|
306
|
+
Active docs: 3
|
|
307
|
+
readme: 5 clients
|
|
308
|
+
meeting-notes: 4 clients
|
|
309
|
+
todo: 3 clients
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
### Custom File Handling
|
|
315
|
+
|
|
316
|
+
Access the underlying Yjs documents:
|
|
317
|
+
|
|
318
|
+
```javascript
|
|
319
|
+
import { createServer } from 'mrmd-sync';
|
|
320
|
+
|
|
321
|
+
const server = createServer({ dir: './docs', port: 4444 });
|
|
322
|
+
|
|
323
|
+
// Get a specific document
|
|
324
|
+
const doc = server.getDoc('readme');
|
|
325
|
+
|
|
326
|
+
// Access Yjs Y.Text
|
|
327
|
+
const content = doc.ytext.toString();
|
|
328
|
+
console.log('Current content:', content);
|
|
329
|
+
|
|
330
|
+
// Watch for changes
|
|
331
|
+
doc.ydoc.on('update', () => {
|
|
332
|
+
console.log('Document updated!');
|
|
333
|
+
console.log('New content:', doc.ytext.toString());
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Programmatically edit (syncs to all clients!)
|
|
337
|
+
doc.ytext.insert(0, '# Hello\n\n');
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
### Subdirectory Support
|
|
343
|
+
|
|
344
|
+
Organize documents in folders:
|
|
345
|
+
|
|
346
|
+
```javascript
|
|
347
|
+
const server = createServer({ dir: './docs', port: 4444 });
|
|
348
|
+
|
|
349
|
+
// These all work:
|
|
350
|
+
// ws://localhost:4444/readme → ./docs/readme.md
|
|
351
|
+
// ws://localhost:4444/notes/daily → ./docs/notes/daily.md
|
|
352
|
+
// ws://localhost:4444/2024/jan/01 → ./docs/2024/jan/01.md
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Browser side:**
|
|
356
|
+
|
|
357
|
+
```javascript
|
|
358
|
+
const drive = mrmd.drive('ws://localhost:4444');
|
|
359
|
+
|
|
360
|
+
// Open nested documents
|
|
361
|
+
drive.open('notes/daily', '#editor1');
|
|
362
|
+
drive.open('2024/jan/01', '#editor2');
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Configuration
|
|
368
|
+
|
|
369
|
+
### Full Options Reference
|
|
370
|
+
|
|
371
|
+
```javascript
|
|
372
|
+
createServer({
|
|
373
|
+
// === Directory & Port ===
|
|
374
|
+
dir: './docs', // Base directory for .md files
|
|
375
|
+
port: 4444, // WebSocket server port
|
|
376
|
+
|
|
377
|
+
// === Authentication ===
|
|
378
|
+
auth: async (req, docName) => {
|
|
379
|
+
// req: HTTP upgrade request (has headers, url, etc.)
|
|
380
|
+
// docName: requested document name
|
|
381
|
+
// Return true to allow, false to reject
|
|
382
|
+
return true;
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
// === Performance ===
|
|
386
|
+
debounceMs: 1000, // Delay before writing to disk (ms)
|
|
387
|
+
// Lower = faster saves, more disk I/O
|
|
388
|
+
// Higher = batched writes, less I/O
|
|
389
|
+
|
|
390
|
+
// === Limits ===
|
|
391
|
+
maxConnections: 100, // Total WebSocket connections allowed
|
|
392
|
+
maxConnectionsPerDoc: 50,// Connections per document
|
|
393
|
+
maxMessageSize: 1048576, // Max WebSocket message (1MB)
|
|
394
|
+
maxFileSize: 10485760, // Max file size to sync (10MB)
|
|
395
|
+
|
|
396
|
+
// === Timeouts ===
|
|
397
|
+
pingIntervalMs: 30000, // Heartbeat ping interval (30s)
|
|
398
|
+
// Detects dead connections
|
|
399
|
+
docCleanupDelayMs: 60000,// Cleanup delay after last disconnect (60s)
|
|
400
|
+
// Keeps doc in memory briefly for reconnects
|
|
401
|
+
|
|
402
|
+
// === Security ===
|
|
403
|
+
dangerouslyAllowSystemPaths: false, // Must be true for /, /etc, /home, etc.
|
|
404
|
+
});
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Recommended Settings by Use Case
|
|
408
|
+
|
|
409
|
+
**Local Development:**
|
|
410
|
+
```javascript
|
|
411
|
+
{
|
|
412
|
+
dir: './docs',
|
|
413
|
+
port: 4444,
|
|
414
|
+
debounceMs: 500, // Fast feedback
|
|
415
|
+
docCleanupDelayMs: 5000, // Quick cleanup
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**Team Collaboration:**
|
|
420
|
+
```javascript
|
|
421
|
+
{
|
|
422
|
+
dir: './team-docs',
|
|
423
|
+
port: 4444,
|
|
424
|
+
maxConnections: 50,
|
|
425
|
+
maxConnectionsPerDoc: 20,
|
|
426
|
+
debounceMs: 1000,
|
|
427
|
+
auth: validateTeamMember,
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Public Documentation:**
|
|
432
|
+
```javascript
|
|
433
|
+
{
|
|
434
|
+
dir: './public-docs',
|
|
435
|
+
port: 4444,
|
|
436
|
+
maxConnections: 500,
|
|
437
|
+
maxConnectionsPerDoc: 200,
|
|
438
|
+
debounceMs: 2000, // Reduce write load
|
|
439
|
+
maxFileSize: 1048576, // 1MB limit
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## HTTP Endpoints
|
|
446
|
+
|
|
447
|
+
The sync server exposes HTTP endpoints for monitoring and health checks.
|
|
448
|
+
|
|
449
|
+
> **Note:** These endpoints are **unauthenticated** by design - they're intended for internal monitoring (load balancers, Kubernetes probes, Prometheus, etc.). If you need to protect them, put a reverse proxy in front.
|
|
450
|
+
|
|
451
|
+
### `GET /health` or `GET /healthz`
|
|
452
|
+
|
|
453
|
+
Health check for load balancers and orchestrators.
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
curl http://localhost:4444/health
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
```json
|
|
460
|
+
{
|
|
461
|
+
"status": "healthy",
|
|
462
|
+
"shutting_down": false
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Returns `200` when healthy, `503` when shutting down.
|
|
467
|
+
|
|
468
|
+
### `GET /metrics`
|
|
469
|
+
|
|
470
|
+
Server metrics in JSON format.
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
curl http://localhost:4444/metrics
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
```json
|
|
477
|
+
{
|
|
478
|
+
"uptime": 3600,
|
|
479
|
+
"connections": {
|
|
480
|
+
"total": 150,
|
|
481
|
+
"active": 12
|
|
482
|
+
},
|
|
483
|
+
"messages": {
|
|
484
|
+
"total": 45230,
|
|
485
|
+
"bytesIn": 1048576,
|
|
486
|
+
"bytesOut": 2097152
|
|
487
|
+
},
|
|
488
|
+
"files": {
|
|
489
|
+
"saves": 89,
|
|
490
|
+
"loads": 23
|
|
491
|
+
},
|
|
492
|
+
"errors": 0,
|
|
493
|
+
"lastActivity": "2024-01-15T10:30:00.000Z"
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### `GET /stats`
|
|
498
|
+
|
|
499
|
+
Detailed statistics including per-document breakdown.
|
|
500
|
+
|
|
501
|
+
```bash
|
|
502
|
+
curl http://localhost:4444/stats
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
```json
|
|
506
|
+
{
|
|
507
|
+
"uptime": 3600,
|
|
508
|
+
"connections": { "total": 150, "active": 12 },
|
|
509
|
+
"documents": [
|
|
510
|
+
{ "name": "readme", "connections": 5, "path": "/docs/readme.md" },
|
|
511
|
+
{ "name": "notes/daily", "connections": 7, "path": "/docs/notes/daily.md" }
|
|
512
|
+
],
|
|
513
|
+
"config": {
|
|
514
|
+
"port": 4444,
|
|
515
|
+
"dir": "/docs",
|
|
516
|
+
"debounceMs": 1000,
|
|
517
|
+
"maxConnections": 100,
|
|
518
|
+
"maxConnectionsPerDoc": 50
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## API Reference
|
|
526
|
+
|
|
527
|
+
### `createServer(options)`
|
|
528
|
+
|
|
529
|
+
Creates and starts a sync server.
|
|
530
|
+
|
|
531
|
+
**Parameters:**
|
|
532
|
+
|
|
533
|
+
| Option | Type | Default | Description |
|
|
534
|
+
|--------|------|---------|-------------|
|
|
535
|
+
| `dir` | `string` | `'./docs'` | Directory to sync |
|
|
536
|
+
| `port` | `number` | `4444` | WebSocket port |
|
|
537
|
+
| `auth` | `function` | `null` | Auth handler `(req, docName) => boolean \| Promise<boolean>` |
|
|
538
|
+
| `debounceMs` | `number` | `1000` | Write debounce delay |
|
|
539
|
+
| `maxConnections` | `number` | `100` | Max total connections |
|
|
540
|
+
| `maxConnectionsPerDoc` | `number` | `50` | Max connections per doc |
|
|
541
|
+
| `maxMessageSize` | `number` | `1048576` | Max message size (bytes) |
|
|
542
|
+
| `maxFileSize` | `number` | `10485760` | Max file size (bytes) |
|
|
543
|
+
| `pingIntervalMs` | `number` | `30000` | Heartbeat interval |
|
|
544
|
+
| `docCleanupDelayMs` | `number` | `60000` | Cleanup delay |
|
|
545
|
+
| `dangerouslyAllowSystemPaths` | `boolean` | `false` | Allow syncing `/`, `/etc`, `/home`, etc. |
|
|
546
|
+
|
|
547
|
+
**Returns:** `Server` object
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
### Server Object
|
|
552
|
+
|
|
553
|
+
```javascript
|
|
554
|
+
const server = createServer(options);
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
#### `server.getDoc(name)`
|
|
558
|
+
|
|
559
|
+
Get or create a document by name.
|
|
560
|
+
|
|
561
|
+
```javascript
|
|
562
|
+
const doc = server.getDoc('readme');
|
|
563
|
+
// doc.ydoc - Y.Doc instance
|
|
564
|
+
// doc.ytext - Y.Text instance
|
|
565
|
+
// doc.awareness - Awareness instance
|
|
566
|
+
// doc.conns - Set of WebSocket connections
|
|
567
|
+
// doc.filePath - Path to .md file
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
#### `server.getStats()`
|
|
571
|
+
|
|
572
|
+
Get current statistics.
|
|
573
|
+
|
|
574
|
+
```javascript
|
|
575
|
+
const stats = server.getStats();
|
|
576
|
+
// {
|
|
577
|
+
// totalConnections: 12,
|
|
578
|
+
// totalDocs: 3,
|
|
579
|
+
// docs: [
|
|
580
|
+
// { name: 'readme', connections: 5 },
|
|
581
|
+
// { name: 'notes', connections: 7 },
|
|
582
|
+
// ]
|
|
583
|
+
// }
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
#### `server.close()`
|
|
587
|
+
|
|
588
|
+
Gracefully shutdown the server.
|
|
589
|
+
|
|
590
|
+
```javascript
|
|
591
|
+
server.close();
|
|
592
|
+
// - Closes all WebSocket connections
|
|
593
|
+
// - Stops file watcher
|
|
594
|
+
// - Cleans up Y.Doc instances
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
#### `server.config`
|
|
598
|
+
|
|
599
|
+
Access the resolved configuration.
|
|
600
|
+
|
|
601
|
+
```javascript
|
|
602
|
+
console.log(server.config);
|
|
603
|
+
// { dir: './docs', port: 4444, debounceMs: 1000, ... }
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
#### `server.wss`
|
|
607
|
+
|
|
608
|
+
Access the underlying WebSocket server (ws library).
|
|
609
|
+
|
|
610
|
+
```javascript
|
|
611
|
+
server.wss.clients.forEach(client => {
|
|
612
|
+
console.log('Client connected');
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
#### `server.docs`
|
|
617
|
+
|
|
618
|
+
Access the Map of loaded documents.
|
|
619
|
+
|
|
620
|
+
```javascript
|
|
621
|
+
server.docs.forEach((doc, name) => {
|
|
622
|
+
console.log(`${name}: ${doc.ytext.length} chars`);
|
|
623
|
+
});
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## How It Works
|
|
629
|
+
|
|
630
|
+
### Architecture
|
|
631
|
+
|
|
632
|
+
```
|
|
633
|
+
┌─────────────────────────────────────────────────────────┐
|
|
634
|
+
│ mrmd-sync │
|
|
635
|
+
├─────────────────────────────────────────────────────────┤
|
|
636
|
+
│ │
|
|
637
|
+
│ Browser A ──┐ │
|
|
638
|
+
│ │ ┌──────────┐ ┌──────────────┐ │
|
|
639
|
+
│ Browser B ──┼────►│ Y.Doc │◄───►│ readme.md │ │
|
|
640
|
+
│ │ │ (CRDT) │ │ (on disk) │ │
|
|
641
|
+
│ Browser C ──┘ └──────────┘ └──────────────┘ │
|
|
642
|
+
│ │ │
|
|
643
|
+
│ Yjs Protocol │
|
|
644
|
+
│ - Sync updates │
|
|
645
|
+
│ - Awareness (cursors) │
|
|
646
|
+
│ │
|
|
647
|
+
└─────────────────────────────────────────────────────────┘
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### Sync Flow
|
|
651
|
+
|
|
652
|
+
**Browser → File:**
|
|
653
|
+
1. User types in browser
|
|
654
|
+
2. mrmd-editor sends Yjs update via WebSocket
|
|
655
|
+
3. Server applies update to Y.Doc
|
|
656
|
+
4. Server broadcasts to other clients
|
|
657
|
+
5. Debounced write to `.md` file
|
|
658
|
+
|
|
659
|
+
**File → Browser:**
|
|
660
|
+
1. External edit (VS Code, git pull, etc.)
|
|
661
|
+
2. Chokidar detects file change
|
|
662
|
+
3. Server reads new content
|
|
663
|
+
4. Character-level diff computed
|
|
664
|
+
5. Diff applied to Y.Doc
|
|
665
|
+
6. Update broadcast to all browsers
|
|
666
|
+
|
|
667
|
+
### Why Yjs?
|
|
668
|
+
|
|
669
|
+
[Yjs](https://yjs.dev) is a CRDT (Conflict-free Replicated Data Type) library that:
|
|
670
|
+
|
|
671
|
+
- **Never loses data** - Concurrent edits merge automatically
|
|
672
|
+
- **Works offline** - Changes sync when reconnected
|
|
673
|
+
- **Character-level sync** - Only changed characters transmitted
|
|
674
|
+
- **Proven** - Used by Notion, Linear, and others
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## Security
|
|
679
|
+
|
|
680
|
+
### System Path Protection
|
|
681
|
+
|
|
682
|
+
By default, mrmd-sync **refuses to sync system directories** like `/`, `/etc`, `/home`, `/var`, etc. This prevents accidentally exposing sensitive system files over WebSocket.
|
|
683
|
+
|
|
684
|
+
```bash
|
|
685
|
+
# This will be REJECTED:
|
|
686
|
+
mrmd-sync /
|
|
687
|
+
mrmd-sync /home
|
|
688
|
+
mrmd-sync /etc/myapp
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
```
|
|
692
|
+
Error: Refusing to sync dangerous system path: "/"
|
|
693
|
+
|
|
694
|
+
Syncing system directories (/, /etc, /home, etc.) can expose sensitive files
|
|
695
|
+
and allow remote file creation anywhere on your system.
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### When You Actually Need System Access
|
|
699
|
+
|
|
700
|
+
If you're building a personal file server, NAS interface, or system admin tool, you can explicitly opt-in:
|
|
701
|
+
|
|
702
|
+
**CLI:**
|
|
703
|
+
```bash
|
|
704
|
+
# The flag name makes you think twice
|
|
705
|
+
mrmd-sync --i-know-what-i-am-doing /
|
|
706
|
+
|
|
707
|
+
# Or the longer version
|
|
708
|
+
mrmd-sync --dangerous-allow-system-paths /home
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
**Programmatic:**
|
|
712
|
+
```javascript
|
|
713
|
+
import { createServer } from 'mrmd-sync';
|
|
714
|
+
|
|
715
|
+
const server = createServer({
|
|
716
|
+
dir: '/',
|
|
717
|
+
dangerouslyAllowSystemPaths: true, // Required for /, /etc, /home, etc.
|
|
718
|
+
|
|
719
|
+
// STRONGLY RECOMMENDED: Add authentication!
|
|
720
|
+
auth: async (req, docName) => {
|
|
721
|
+
const token = getTokenFromRequest(req);
|
|
722
|
+
return validateToken(token);
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
### What's Considered Dangerous?
|
|
728
|
+
|
|
729
|
+
These paths require explicit opt-in:
|
|
730
|
+
|
|
731
|
+
| Path | Why It's Dangerous |
|
|
732
|
+
|------|-------------------|
|
|
733
|
+
| `/` | Access to entire filesystem |
|
|
734
|
+
| `/etc` | System configuration files |
|
|
735
|
+
| `/home` | All users' home directories |
|
|
736
|
+
| `/var` | System logs, databases |
|
|
737
|
+
| `/usr` | System binaries |
|
|
738
|
+
| `/root` | Root user's home |
|
|
739
|
+
| `/bin`, `/sbin` | System executables |
|
|
740
|
+
|
|
741
|
+
**Safe by default (no flag needed):**
|
|
742
|
+
- `/home/youruser/docs` - User-specific subdirectory
|
|
743
|
+
- `./docs` - Relative paths
|
|
744
|
+
- `/srv/myapp/data` - Application-specific paths
|
|
745
|
+
|
|
746
|
+
### Best Practices for System Access
|
|
747
|
+
|
|
748
|
+
If you enable system path access:
|
|
749
|
+
|
|
750
|
+
1. **Always use authentication**
|
|
751
|
+
```javascript
|
|
752
|
+
createServer({
|
|
753
|
+
dir: '/',
|
|
754
|
+
dangerouslyAllowSystemPaths: true,
|
|
755
|
+
auth: (req, docName) => validateUser(req),
|
|
756
|
+
})
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
2. **Run as unprivileged user**
|
|
760
|
+
```bash
|
|
761
|
+
# Don't run as root!
|
|
762
|
+
sudo -u www-data mrmd-sync --i-know-what-i-am-doing /srv/files
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
3. **Use a reverse proxy with TLS**
|
|
766
|
+
```nginx
|
|
767
|
+
location /sync {
|
|
768
|
+
proxy_pass http://localhost:4444;
|
|
769
|
+
proxy_http_version 1.1;
|
|
770
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
771
|
+
proxy_set_header Connection "upgrade";
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
4. **Consider read-only for sensitive paths**
|
|
776
|
+
```javascript
|
|
777
|
+
auth: async (req, docName) => {
|
|
778
|
+
// Block writes to sensitive directories
|
|
779
|
+
if (docName.startsWith('etc/') || docName.startsWith('var/')) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
return validateUser(req);
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
---
|
|
787
|
+
|
|
788
|
+
## Operational Notes
|
|
789
|
+
|
|
790
|
+
### Instance Locking
|
|
791
|
+
|
|
792
|
+
mrmd-sync uses a PID file (`.mrmd-sync/server.pid`) to prevent multiple instances from running on the same directory. If you try to start a second instance:
|
|
793
|
+
|
|
794
|
+
```
|
|
795
|
+
Error: Another mrmd-sync instance is already running on this directory.
|
|
796
|
+
|
|
797
|
+
PID: 12345
|
|
798
|
+
Port: 4444
|
|
799
|
+
Started: 2024-01-15T10:30:00.000Z
|
|
800
|
+
|
|
801
|
+
Stop the other instance first, or use a different directory.
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
If a previous instance crashed without cleanup, the stale PID file is automatically detected and removed.
|
|
805
|
+
|
|
806
|
+
### Yjs Document Growth
|
|
807
|
+
|
|
808
|
+
Yjs stores operation history for undo/redo and conflict resolution. Long-running sessions with many edits will accumulate history, increasing memory usage. This is expected behavior - documents are cleaned up after clients disconnect (controlled by `docCleanupDelayMs`).
|
|
809
|
+
|
|
810
|
+
For high-traffic scenarios, consider:
|
|
811
|
+
- Lower `docCleanupDelayMs` to free memory faster
|
|
812
|
+
- Restart periodically during maintenance windows
|
|
813
|
+
|
|
814
|
+
### External File Edits
|
|
815
|
+
|
|
816
|
+
When files are edited externally (VS Code, git pull, etc.):
|
|
817
|
+
1. Chokidar detects the change
|
|
818
|
+
2. A character-level diff is computed
|
|
819
|
+
3. Changes are applied to the Yjs document
|
|
820
|
+
4. All connected clients receive the update
|
|
821
|
+
|
|
822
|
+
This is intentional - the `.md` file is the source of truth. However, simultaneous external edits and browser edits may result in merged content.
|
|
823
|
+
|
|
824
|
+
### Crash Recovery
|
|
825
|
+
|
|
826
|
+
If `persistYjsState: true` (default), Yjs snapshots are saved to `.mrmd-sync/*.yjs`. On restart:
|
|
827
|
+
1. Snapshot is loaded (contains recent edits)
|
|
828
|
+
2. File is read
|
|
829
|
+
3. If they differ, the file wins (it's the source of truth)
|
|
830
|
+
|
|
831
|
+
This protects against corrupt snapshots but means edits between the last file write and a crash may be lost. The `debounceMs` setting controls how quickly edits are persisted to the file.
|
|
832
|
+
|
|
833
|
+
---
|
|
834
|
+
|
|
835
|
+
## Troubleshooting
|
|
836
|
+
|
|
837
|
+
### Connection Rejected
|
|
838
|
+
|
|
839
|
+
**"Max connections reached"**
|
|
840
|
+
```javascript
|
|
841
|
+
// Increase limit
|
|
842
|
+
createServer({ maxConnections: 200 })
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
**"Invalid document name"**
|
|
846
|
+
```
|
|
847
|
+
// Document names must:
|
|
848
|
+
// - Not contain ".."
|
|
849
|
+
// - Not start with "/" or "\"
|
|
850
|
+
// - Only contain: letters, numbers, dashes, underscores, dots, slashes
|
|
851
|
+
//
|
|
852
|
+
// Valid: readme, notes/daily, 2024-01-01, my_doc
|
|
853
|
+
// Invalid: ../secret, /etc/passwd, doc<script>
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
**"Unauthorized"**
|
|
857
|
+
```javascript
|
|
858
|
+
// Check your auth function
|
|
859
|
+
auth: async (req, docName) => {
|
|
860
|
+
console.log('Auth attempt:', docName, req.headers);
|
|
861
|
+
// Make sure you return true for valid requests
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
### File Not Syncing
|
|
867
|
+
|
|
868
|
+
**Check file path:**
|
|
869
|
+
```javascript
|
|
870
|
+
const doc = server.getDoc('readme');
|
|
871
|
+
console.log('File path:', doc.filePath);
|
|
872
|
+
// Should be: ./docs/readme.md
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
**Check file permissions:**
|
|
876
|
+
```bash
|
|
877
|
+
ls -la ./docs/
|
|
878
|
+
# Ensure writable
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
**Check file size:**
|
|
882
|
+
```javascript
|
|
883
|
+
// Default max is 10MB
|
|
884
|
+
createServer({ maxFileSize: 50 * 1024 * 1024 }) // 50MB
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
### High Memory Usage
|
|
888
|
+
|
|
889
|
+
**Reduce cleanup delay:**
|
|
890
|
+
```javascript
|
|
891
|
+
createServer({
|
|
892
|
+
docCleanupDelayMs: 10000, // 10s instead of 60s
|
|
893
|
+
})
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
**Check for leaked connections:**
|
|
897
|
+
```javascript
|
|
898
|
+
setInterval(() => {
|
|
899
|
+
const stats = server.getStats();
|
|
900
|
+
console.log('Active docs:', stats.totalDocs);
|
|
901
|
+
console.log('Connections:', stats.totalConnections);
|
|
902
|
+
}, 5000);
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
### Slow Performance
|
|
906
|
+
|
|
907
|
+
**Increase debounce for write-heavy workloads:**
|
|
908
|
+
```javascript
|
|
909
|
+
createServer({
|
|
910
|
+
debounceMs: 2000, // 2 seconds
|
|
911
|
+
})
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
**Reduce max file size:**
|
|
915
|
+
```javascript
|
|
916
|
+
createServer({
|
|
917
|
+
maxFileSize: 1024 * 1024, // 1MB
|
|
918
|
+
})
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
---
|
|
922
|
+
|
|
923
|
+
## License
|
|
924
|
+
|
|
925
|
+
MIT
|