memd-cli 2.1.0 → 3.0.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/main.js CHANGED
@@ -1,14 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-nocheck
3
- import { marked, Marked } from 'marked';
3
+ import { marked } from 'marked';
4
4
  import { markedTerminal } from 'marked-terminal';
5
- import { renderMermaidASCII, renderMermaidSVG, THEMES as MERMAID_THEMES } from 'beautiful-mermaid';
5
+ import { renderMermaidASCII, THEMES as MERMAID_THEMES } from 'beautiful-mermaid';
6
6
  import chalk from 'chalk';
7
7
  import { program } from 'commander';
8
8
  import { spawn } from 'child_process';
9
+ import { createServer } from 'http';
10
+ import { Worker } from 'node:worker_threads';
11
+ import os from 'node:os';
12
+ import crypto from 'node:crypto';
13
+ import { gzip as gzipCb } from 'node:zlib';
14
+ import { promisify } from 'node:util';
15
+ const gzipAsync = promisify(gzipCb);
9
16
  import * as fs from 'fs';
10
17
  import * as path from 'path';
11
18
  import { fileURLToPath } from 'url';
19
+ import { escapeHtml, mixHex, MIX, resolveThemeColors } from './render-utils.js';
20
+
21
+ function envInt(name, fallback) {
22
+ const v = process.env[name];
23
+ if (v == null) return fallback;
24
+ const n = Number(v);
25
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
26
+ }
27
+
28
+ const STATIC_MIME = {
29
+ '.png': 'image/png',
30
+ '.jpg': 'image/jpeg',
31
+ '.jpeg': 'image/jpeg',
32
+ '.gif': 'image/gif',
33
+ '.svg': 'image/svg+xml',
34
+ '.webp': 'image/webp',
35
+ '.ico': 'image/x-icon',
36
+ '.avif': 'image/avif',
37
+ '.css': 'text/css',
38
+ };
12
39
 
13
40
  import { createHighlighterCoreSync } from 'shiki/core';
14
41
  import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
@@ -75,33 +102,11 @@ const THEME_MAP = {
75
102
  // Single source of truth for available theme names (used in --help and validation)
76
103
  const THEME_NAMES = Object.keys(THEME_MAP);
77
104
 
78
- // Color mixing: blend hex1 into hex2 at pct% (sRGB linear interpolation)
79
- // Equivalent to CSS color-mix(in srgb, hex1 pct%, hex2)
80
- function mixHex(hex1, hex2, pct) {
81
- const p = pct / 100;
82
- const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
83
- const mix = (c1, c2) => Math.round(c1 * p + c2 * (1 - p));
84
- const toHex = x => x.toString(16).padStart(2, '0');
85
- const r = mix(parse(hex1, 1), parse(hex2, 1));
86
- const g = mix(parse(hex1, 3), parse(hex2, 3));
87
- const b = mix(parse(hex1, 5), parse(hex2, 5));
88
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
89
- }
90
-
91
- // MIX ratios from beautiful-mermaid theme.ts:64-87
92
- const MIX = { line: 50, arrow: 85, textSec: 60, nodeStroke: 20 };
93
105
 
94
106
  // Gentle desaturation by blending toward neutral gray (#808080) in sRGB space.
95
- // amount=0.15 means 15% toward gray. Replaces the old mute() (HSL-based) with a simpler,
96
- // more predictable sRGB blend that avoids hue shifts from HSL rounding.
107
+ // amount=0.15 means 15% toward gray.
97
108
  function softenHex(hex, amount = 0.15) {
98
- const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
99
- const mix = (c, gray) => Math.round(c * (1 - amount) + gray * amount);
100
- const toHex = x => x.toString(16).padStart(2, '0');
101
- const r = mix(parse(hex, 1), 128);
102
- const g = mix(parse(hex, 3), 128);
103
- const b = mix(parse(hex, 5), 128);
104
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
109
+ return mixHex('#808080', hex, amount * 100);
105
110
  }
106
111
 
107
112
  function tokensToAnsi(lines) {
@@ -187,44 +192,26 @@ function diagramColorsToAsciiTheme(colors) {
187
192
  };
188
193
  }
189
194
 
190
- // Resolve optional DiagramColors fields for HTML template CSS
191
- function resolveThemeColors(colors) {
192
- return {
193
- bg: colors.bg,
194
- fg: colors.fg,
195
- line: colors.line ?? mixHex(colors.fg, colors.bg, MIX.line),
196
- accent: colors.accent ?? mixHex(colors.fg, colors.bg, MIX.arrow),
197
- muted: colors.muted ?? mixHex(colors.fg, colors.bg, MIX.textSec),
198
- border: colors.border ?? mixHex(colors.fg, colors.bg, MIX.nodeStroke),
199
- };
200
- }
201
-
202
- function escapeHtml(str) {
203
- return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
195
+ function isPathWithinBase(baseDir, resolvedPath) {
196
+ if (resolvedPath === baseDir) return true;
197
+ const prefix = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep;
198
+ return resolvedPath.startsWith(prefix);
204
199
  }
205
200
 
201
+ // CLI command: directory traversal is intentionally allowed.
202
+ // The CLI runs locally as the invoking user, so OS file permissions are the
203
+ // sole access-control layer -- same as cat, less, or any other local tool.
204
+ // The serve subcommand does NOT allow traversal; it confines all access to
205
+ // --dir via resolveServePath + checkSymlinkEscape to prevent network-exposed
206
+ // path traversal (DNS rebinding, SSRF, etc.).
206
207
  function readMarkdownFile(filePath) {
207
208
  const absolutePath = path.resolve(filePath);
208
- const currentDir = process.cwd();
209
- const currentDirResolved = path.resolve(currentDir);
210
- const relativePath = path.relative(currentDirResolved, absolutePath);
211
-
212
- if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
213
- throw new Error('Invalid path: access outside current directory is not allowed');
209
+ try {
210
+ return fs.readFileSync(fs.realpathSync(absolutePath), 'utf-8');
211
+ } catch (e) {
212
+ if (e.code === 'ENOENT') throw new Error('File not found: ' + absolutePath);
213
+ throw e;
214
214
  }
215
-
216
- if (!fs.existsSync(absolutePath)) {
217
- throw new Error('File not found: ' + absolutePath);
218
- }
219
-
220
- // Resolve symlinks and re-check to prevent symlink-based traversal
221
- const realPath = fs.realpathSync(absolutePath);
222
- const realRelative = path.relative(currentDirResolved, realPath);
223
- if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
224
- throw new Error('Invalid path: access outside current directory is not allowed');
225
- }
226
-
227
- return fs.readFileSync(realPath, 'utf-8');
228
215
  }
229
216
 
230
217
  function convertMermaidToAscii(markdown, { useAscii = false, colorMode, theme } = {}) {
@@ -247,56 +234,270 @@ function convertMermaidToAscii(markdown, { useAscii = false, colorMode, theme }
247
234
  });
248
235
  }
249
236
 
250
- function convertMermaidToSVG(markdown, diagramTheme) {
251
- const mermaidRegex = /```mermaid\s+([\s\S]+?)```/g;
252
- let svgIndex = 0;
253
- return markdown.replace(mermaidRegex, (_, code) => {
254
- try {
255
- const prefix = `m${svgIndex++}`;
256
- let svg = renderMermaidSVG(code.trim(), diagramTheme);
257
- svg = svg.replace(/@import url\([^)]+\);\s*/g, '');
258
- // Prefix all id="..." and url(#...) to avoid cross-SVG collisions
259
- // Note: regex uses ` id=` (with leading space) to avoid matching `data-id`
260
- svg = svg.replace(/ id="([^"]+)"/g, ` id="${prefix}-$1"`);
261
- svg = svg.replace(/url\(#([^)]+)\)/g, `url(#${prefix}-$1)`);
262
- return svg;
263
- } catch (e) {
264
- return `<pre class="mermaid-error">${escapeHtml(e.message)}\n\n${escapeHtml(code.trim())}</pre>`;
237
+
238
+ function etagMatch(ifNoneMatch, etag) {
239
+ if (!ifNoneMatch) return false;
240
+ if (ifNoneMatch === etag) return true;
241
+ return ifNoneMatch.split(',').some(t => t.trim() === etag);
242
+ }
243
+
244
+ function createRenderPool(workerPath, poolSize, opts = {}) {
245
+ const workers = [];
246
+ const activeRequest = new Map();
247
+ const deadWorkers = new Set();
248
+ const deadTimestamps = new Map();
249
+ const terminatingWorkers = new Set();
250
+ const waitQueue = [];
251
+ let nextId = 0;
252
+ let terminated = false;
253
+
254
+ const RESPAWN_MAX = opts.respawnMax ?? 5;
255
+ const RESPAWN_WINDOW_MS = opts.respawnWindowMs ?? 60_000;
256
+ const RENDER_TIMEOUT_MS = opts.renderTimeoutMs ?? 30_000;
257
+ const respawnTimestamps = new Map();
258
+
259
+ function spawnWorker(idx) {
260
+ const w = new Worker(workerPath);
261
+ w.on('message', (msg) => handleMessage(idx, msg));
262
+ w.on('error', (err) => console.error(`Worker ${idx} error: ${err.message}`));
263
+ w.on('exit', (code) => handleExit(idx, code));
264
+ return w;
265
+ }
266
+
267
+ function handleMessage(workerIndex, { id, html, error }) {
268
+ const req = activeRequest.get(workerIndex);
269
+ if (!req || req.id !== id) return;
270
+ clearTimeout(req.timer);
271
+ activeRequest.delete(workerIndex);
272
+ error ? req.reject(new Error(error)) : req.resolve(html);
273
+ drainWaitQueue();
274
+ }
275
+
276
+ function findAvailableWorker() {
277
+ for (let i = 0; i < workers.length; i++) {
278
+ if (deadWorkers.has(i) || terminatingWorkers.has(i)) continue;
279
+ if (!activeRequest.has(i)) return i;
265
280
  }
266
- });
281
+ return -1;
282
+ }
283
+
284
+ function dispatch(workerIndex, markdown, diagramColors) {
285
+ const id = nextId;
286
+ nextId = (nextId + 1) % Number.MAX_SAFE_INTEGER;
287
+ return new Promise((resolve, reject) => {
288
+ const timer = setTimeout(() => {
289
+ activeRequest.delete(workerIndex);
290
+ terminatingWorkers.add(workerIndex);
291
+ workers[workerIndex].terminate();
292
+ reject(new Error('Render timed out'));
293
+ }, RENDER_TIMEOUT_MS);
294
+ activeRequest.set(workerIndex, { id, resolve, reject, timer });
295
+ workers[workerIndex].postMessage({ id, markdown, diagramColors });
296
+ });
297
+ }
298
+
299
+ function drainWaitQueue() {
300
+ while (waitQueue.length > 0) {
301
+ const idx = findAvailableWorker();
302
+ if (idx === -1) break;
303
+ const entry = waitQueue.shift();
304
+ clearTimeout(entry.timer);
305
+ dispatch(idx, entry.markdown, entry.diagramColors).then(entry.resolve, entry.reject);
306
+ }
307
+ }
308
+
309
+ function handleExit(workerIndex, code) {
310
+ const req = activeRequest.get(workerIndex);
311
+ if (req) {
312
+ clearTimeout(req.timer);
313
+ req.reject(new Error(`Worker exited with code ${code}`));
314
+ activeRequest.delete(workerIndex);
315
+ }
316
+ terminatingWorkers.delete(workerIndex);
317
+ if (terminated) return;
318
+
319
+ const now = Date.now();
320
+ const history = (respawnTimestamps.get(workerIndex) || []).filter(t => now - t < RESPAWN_WINDOW_MS);
321
+ if (history.length >= RESPAWN_MAX) {
322
+ console.error(`Worker ${workerIndex} crashed ${RESPAWN_MAX} times in ${RESPAWN_WINDOW_MS / 1000}s, not respawning`);
323
+ deadWorkers.add(workerIndex);
324
+ deadTimestamps.set(workerIndex, Date.now());
325
+ drainWaitQueue();
326
+ return;
327
+ }
328
+ history.push(now);
329
+ respawnTimestamps.set(workerIndex, history);
330
+ workers[workerIndex] = spawnWorker(workerIndex);
331
+ drainWaitQueue();
332
+ }
333
+
334
+ for (let i = 0; i < poolSize; i++) {
335
+ workers.push(spawnWorker(i));
336
+ }
337
+
338
+ const DEAD_RECOVERY_MS = opts.deadRecoveryMs ?? 5 * 60_000;
339
+ const DEAD_RECOVERY_CHECK_MS = 30_000;
340
+
341
+ function recoverDeadWorkers(force = false) {
342
+ let recovered = false;
343
+ for (const idx of [...deadWorkers]) {
344
+ const deadTime = deadTimestamps.get(idx);
345
+ if (force || (deadTime && Date.now() - deadTime >= DEAD_RECOVERY_MS)) {
346
+ deadWorkers.delete(idx);
347
+ deadTimestamps.delete(idx);
348
+ respawnTimestamps.delete(idx);
349
+ workers[idx] = spawnWorker(idx);
350
+ console.log(`Worker ${idx} recovered after cooldown`);
351
+ recovered = true;
352
+ }
353
+ }
354
+ if (recovered) drainWaitQueue();
355
+ }
356
+
357
+ const recoveryTimer = setInterval(() => {
358
+ if (!terminated && deadWorkers.size > 0) {
359
+ recoverDeadWorkers();
360
+ }
361
+ }, DEAD_RECOVERY_CHECK_MS);
362
+ recoveryTimer.unref();
363
+
364
+ return {
365
+ render(markdown, diagramColors) {
366
+ if (deadWorkers.size + terminatingWorkers.size >= workers.length) {
367
+ recoverDeadWorkers(true);
368
+ }
369
+ const idx = findAvailableWorker();
370
+ if (idx !== -1) {
371
+ return dispatch(idx, markdown, diagramColors);
372
+ }
373
+ // waitQueue has no size limit. Each entry is small and has a timeout, so memory
374
+ // growth is bounded by (concurrent requests * RENDER_TIMEOUT_MS). This is fine for
375
+ // a dev server; HTTP server.requestTimeout provides an additional upper bound.
376
+ return new Promise((resolve, reject) => {
377
+ const timer = setTimeout(() => {
378
+ const pos = waitQueue.findIndex(e => e.resolve === resolve);
379
+ if (pos !== -1) waitQueue.splice(pos, 1);
380
+ reject(new Error('All workers are unavailable'));
381
+ }, RENDER_TIMEOUT_MS);
382
+ waitQueue.push({ markdown, diagramColors, resolve, reject, timer });
383
+ });
384
+ },
385
+ terminate() {
386
+ terminated = true;
387
+ clearInterval(recoveryTimer);
388
+ for (const entry of waitQueue) {
389
+ clearTimeout(entry.timer);
390
+ entry.reject(new Error('Pool terminated'));
391
+ }
392
+ waitQueue.length = 0;
393
+ for (const w of workers) w.terminate();
394
+ },
395
+ };
396
+ }
397
+
398
+ // Serve subcommand: directory traversal is intentionally blocked.
399
+ // Unlike the CLI command, serve exposes files over HTTP and may be reachable
400
+ // from the network (--host 0.0.0.0). Allowing traversal would risk leaking
401
+ // arbitrary files via DNS rebinding, SSRF, or direct network access.
402
+ function resolveServePath(baseDir, urlPath) {
403
+ let decoded;
404
+ try {
405
+ decoded = decodeURIComponent(urlPath);
406
+ } catch {
407
+ return null; // invalid URI encoding
408
+ }
409
+ if (decoded.includes('\0')) return null;
410
+ const resolved = path.resolve(path.join(baseDir, decoded));
411
+ return isPathWithinBase(baseDir, resolved) ? resolved : null;
412
+ }
413
+
414
+ async function checkSymlinkEscape(baseDir, fsPath) {
415
+ try {
416
+ const real = await fs.promises.realpath(fsPath);
417
+ return !isPathWithinBase(baseDir, real);
418
+ } catch {
419
+ return true;
420
+ }
421
+ }
422
+
423
+ // '.' and '..' are excluded from dot-path blocking because they are valid path components;
424
+ // '..' traversal is separately caught by resolveServePath's prefix check.
425
+ function isDotPath(urlPath) {
426
+ try {
427
+ return decodeURIComponent(urlPath).split('/').some(seg => seg.startsWith('.') && seg !== '.' && seg !== '..');
428
+ } catch {
429
+ return true;
430
+ }
267
431
  }
268
432
 
269
- function renderToHTML(markdown, diagramColors) {
270
- const processed = convertMermaidToSVG(markdown, diagramColors);
271
- const htmlMarked = new Marked();
272
- const body = htmlMarked.parse(processed);
273
- const t = resolveThemeColors(diagramColors);
433
+ async function readDirResolved(baseDir, dirPath) {
434
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
435
+ const resolved = await Promise.all(entries.map(async (e) => {
436
+ if (e.isSymbolicLink()) {
437
+ const target = path.join(dirPath, e.name);
438
+ if (await checkSymlinkEscape(baseDir, target)) return null;
439
+ try {
440
+ const st = await fs.promises.stat(target);
441
+ return { name: e.name, isDirectory: () => st.isDirectory(), isFile: () => st.isFile() };
442
+ } catch { return null; }
443
+ }
444
+ return e;
445
+ }));
446
+ return resolved.filter(Boolean);
447
+ }
274
448
 
449
+ function buildDirEntryItems(urlPath, dirEntries, activeFileName) {
450
+ const visible = dirEntries.filter(e => !e.name.startsWith('.'));
451
+ const dirs = visible.filter(e => e.isDirectory()).map(e => e.name).sort();
452
+ const mdFiles = visible.filter(e => e.isFile() && e.name.endsWith('.md')).map(e => e.name).sort();
453
+ const items = [];
454
+ if (urlPath !== '/') {
455
+ items.push('<li><a href="../">../</a></li>');
456
+ }
457
+ const dirNames = new Set(dirs);
458
+ for (const d of dirs) {
459
+ items.push(`<li><a href="${encodeURIComponent(d)}/">${escapeHtml(d)}/</a></li>`);
460
+ }
461
+ for (const f of mdFiles) {
462
+ const base = f.slice(0, -3);
463
+ const href = dirNames.has(base) ? encodeURIComponent(f) : encodeURIComponent(base);
464
+ const attrs = activeFileName === f ? ' aria-current="page"' : '';
465
+ items.push(`<li><a href="${href}"${attrs}>${escapeHtml(f)}</a></li>`);
466
+ }
467
+ return items;
468
+ }
469
+
470
+ function renderDirectoryListing(urlPath, dirEntries, themeColors) {
471
+ const t = resolveThemeColors(themeColors);
472
+ const items = buildDirEntryItems(urlPath, dirEntries);
275
473
  return `<!DOCTYPE html>
276
474
  <html lang="en">
277
475
  <head>
278
476
  <meta charset="utf-8">
279
477
  <meta name="viewport" content="width=device-width, initial-scale=1">
478
+ <title>Index of ${escapeHtml(urlPath)}</title>
280
479
  <style>
281
- body { background: ${t.bg}; color: ${t.fg}; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
282
- a { color: ${t.accent}; }
283
- hr { border-color: ${t.line}; }
284
- blockquote { border-left: 3px solid ${t.line}; color: ${t.muted}; padding-left: 1rem; }
285
- svg { max-width: 100%; height: auto; }
286
- pre { background: color-mix(in srgb, ${t.fg} 8%, ${t.bg}); padding: 1rem; border-radius: 6px; overflow-x: auto; }
287
- code { font-size: 0.9em; color: ${t.accent}; }
288
- pre code { color: inherit; }
289
- table { border-collapse: collapse; }
290
- th, td { border: 1px solid ${t.line}; padding: 0.4rem 0.8rem; }
291
- th { background: color-mix(in srgb, ${t.fg} 5%, ${t.bg}); }
292
- .mermaid-error { background: color-mix(in srgb, ${t.accent} 10%, ${t.bg}); border: 1px solid color-mix(in srgb, ${t.accent} 40%, ${t.bg}); color: ${t.fg}; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; }
480
+ body { background: ${t.bg}; color: ${t.fg}; font-family: system-ui, -apple-system, sans-serif; line-height: 1.8; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
481
+ a { color: ${t.accent}; text-decoration: none; }
482
+ a:hover { text-decoration: underline; }
483
+ h1 { font-size: 1.4rem; border-bottom: 1px solid ${t.line}; padding-bottom: 0.5rem; }
484
+ ul { list-style: none; padding: 0; }
485
+ li { padding: 0.2rem 0; }
293
486
  </style>
294
487
  </head>
295
488
  <body>
296
- ${body.trimEnd()}
489
+ <h1>Index of ${escapeHtml(urlPath)}</h1>
490
+ <ul>
491
+ ${items.join('\n')}
492
+ </ul>
493
+ <!--memd:scripts-->
297
494
  </body>
298
- </html>
299
- `;
495
+ </html>`;
496
+ }
497
+
498
+ function buildSidebarHtml(dirUrlPath, activeFileName, dirEntries) {
499
+ const items = buildDirEntryItems(dirUrlPath, dirEntries, activeFileName);
500
+ return `<nav class="memd-sidebar"><ul>\n${items.join('\n')}\n</ul></nav>`;
300
501
  }
301
502
 
302
503
  function readStdin() {
@@ -420,8 +621,10 @@ async function main() {
420
621
 
421
622
  if (options.html) {
422
623
  // 3a. HTML path
624
+ const { renderToHTML, MERMAID_MODAL_SCRIPT } = await import('./render-shared.js');
423
625
  const combined = markdownParts.join('\n\n');
424
- const html = renderToHTML(combined, diagramColors);
626
+ let html = renderToHTML(combined, diagramColors);
627
+ html = html.replace('<!--memd:scripts-->', html.includes('mermaid-diagram') ? `<script>${MERMAID_MODAL_SCRIPT}</script>` : '');
425
628
  process.stdout.write(html);
426
629
  } else {
427
630
  // 3b. Terminal path
@@ -500,6 +703,608 @@ async function main() {
500
703
  }
501
704
  });
502
705
 
706
+ program
707
+ .command('serve')
708
+ .description('Start HTTP server to serve .md files as HTML')
709
+ .option('-d, --dir <path>', 'directory to serve', '.')
710
+ .option('-p, --port <number>', 'port number (0-65535)', Number, 8888)
711
+ .option('--host <string>', 'host to bind', '127.0.0.1')
712
+ .option('--workers <number>', 'number of render workers (default: min(cpus-1, 4))', Number)
713
+ .option('--watch', 'watch for file changes and live-reload')
714
+ .option('--theme <name>', `color theme (env: MEMD_THEME)\n${THEME_NAMES.join(', ')}`, process.env.MEMD_THEME || 'nord')
715
+ .action(async (options) => {
716
+ if (!(options.theme in THEME_MAP)) {
717
+ const names = Object.keys(THEME_MAP).join(', ');
718
+ console.error(`Unknown theme: ${options.theme}\nAvailable themes: ${names}`);
719
+ process.exit(1);
720
+ }
721
+ const themeEntry = THEME_MAP[options.theme];
722
+ const diagramColors = MERMAID_THEMES[themeEntry.mermaidTheme];
723
+ if (!diagramColors) {
724
+ console.error(`Internal error: mermaid theme '${themeEntry.mermaidTheme}' not found`);
725
+ process.exit(1);
726
+ }
727
+
728
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
729
+ console.error('Invalid --port: must be an integer between 0 and 65535');
730
+ process.exit(1);
731
+ }
732
+
733
+ let baseDir;
734
+ try {
735
+ baseDir = fs.realpathSync(path.resolve(options.dir));
736
+ } catch {
737
+ console.error(`Directory not found: ${options.dir}`);
738
+ process.exit(1);
739
+ }
740
+ if (!fs.statSync(baseDir).isDirectory()) {
741
+ console.error(`Not a directory: ${options.dir}`);
742
+ process.exit(1);
743
+ }
744
+ if (baseDir === '/') {
745
+ console.error('Serving the filesystem root (/) is not allowed. Use a subdirectory instead.');
746
+ process.exit(1);
747
+ }
748
+
749
+ if (options.workers !== undefined && (!Number.isInteger(options.workers) || options.workers < 1)) {
750
+ console.error('Invalid --workers: must be a positive integer');
751
+ process.exit(1);
752
+ }
753
+ const { MERMAID_MODAL_SCRIPT: mermaidModalScript } = await import('./render-shared.js');
754
+ const poolSize = options.workers ?? Math.min(Math.max(1, os.cpus().length - 1), 4);
755
+ const workerPath = new URL('./render-worker.js', import.meta.url);
756
+ const pool = createRenderPool(workerPath, poolSize, {
757
+ respawnMax: envInt('MEMD_SERVE_RESPAWN_MAX', 5),
758
+ respawnWindowMs: envInt('MEMD_SERVE_RESPAWN_WINDOW_MS', 60_000),
759
+ renderTimeoutMs: envInt('MEMD_SERVE_RENDER_TIMEOUT_MS', 30_000),
760
+ deadRecoveryMs: envInt('MEMD_SERVE_DEAD_RECOVERY_MS', 5 * 60_000),
761
+ });
762
+
763
+ // Render cache: stores rawHtml (without sidebar) keyed by file path.
764
+ // LRU eviction; byte tracking includes per-entry overhead (~256 bytes for Map entry + key + metadata).
765
+ const CACHE_ENTRY_OVERHEAD = 256;
766
+ const renderCache = new Map();
767
+ let renderCacheBytes = 0;
768
+ const inflight = new Map();
769
+ const CACHE_MAX_ENTRIES = envInt('MEMD_SERVE_CACHE_MAX_ENTRIES', 200);
770
+ const CACHE_MAX_BYTES = envInt('MEMD_SERVE_CACHE_MAX_BYTES', 50 * 1024 * 1024);
771
+ const MD_MAX_SIZE = envInt('MEMD_SERVE_MD_MAX_SIZE', 10 * 1024 * 1024);
772
+ const sseClients = new Set();
773
+
774
+ // Directory entry cache: stores readdir results keyed by dir path (LRU, max 100).
775
+ const dirEntryCache = new Map();
776
+ const DIR_ENTRY_CACHE_MAX = 100;
777
+ // Directory listing HTML cache: keyed by dir path (LRU, max 100).
778
+ const dirListCache = new Map();
779
+ const DIR_LIST_CACHE_MAX = 100;
780
+ // Sidebar HTML cache: keyed by (dirPath + dirMtimeMs + activeFile), LRU max 200.
781
+ const sidebarHtmlCache = new Map();
782
+ const SIDEBAR_CACHE_MAX = 200;
783
+ // Gzip cache: stores compressed Buffer keyed by etag (LRU, max 200).
784
+ // Stale entries (from changed files) are never served because etag changes;
785
+ // they are evicted naturally by LRU.
786
+ const gzipCache = new Map();
787
+ const GZIP_CACHE_MAX = envInt('MEMD_SERVE_GZIP_CACHE_MAX', 200);
788
+ // Session-stable CSP nonce. Using a per-session nonce (instead of per-request)
789
+ // allows gzip results to be cached. This is safe for a dev server: the nonce
790
+ // prevents inline scripts in markdown from executing, and an attacker would need
791
+ // filesystem access to observe it.
792
+ const sessionNonce = crypto.randomBytes(16).toString('base64');
793
+
794
+ const t = resolveThemeColors(diagramColors);
795
+
796
+ // No rate limiting or connection limit is applied; this is a development-only server
797
+ // not intended for production use. Do not expose to untrusted networks.
798
+
799
+ function entryBytes(key, rawHtmlBytes) {
800
+ return rawHtmlBytes + key.length + CACHE_ENTRY_OVERHEAD;
801
+ }
802
+
803
+ function renderCacheGet(key) {
804
+ const entry = renderCache.get(key);
805
+ if (entry) {
806
+ renderCache.delete(key);
807
+ renderCache.set(key, entry);
808
+ }
809
+ return entry;
810
+ }
811
+
812
+ function renderCacheSet(key, entry) {
813
+ const old = renderCache.get(key);
814
+ if (old) {
815
+ renderCacheBytes -= old.totalBytes;
816
+ renderCache.delete(key);
817
+ }
818
+ const totalBytes = entryBytes(key, entry.rawHtmlBytes);
819
+ if (totalBytes > CACHE_MAX_BYTES) return;
820
+ while (renderCache.size >= CACHE_MAX_ENTRIES || (renderCacheBytes + totalBytes > CACHE_MAX_BYTES && renderCache.size > 0)) {
821
+ const oldestKey = renderCache.keys().next().value;
822
+ const evicted = renderCache.get(oldestKey);
823
+ renderCacheBytes -= evicted.totalBytes;
824
+ renderCache.delete(oldestKey);
825
+ }
826
+ entry.totalBytes = totalBytes;
827
+ renderCache.set(key, entry);
828
+ renderCacheBytes += totalBytes;
829
+ }
830
+
831
+ function renderCacheDelete(key) {
832
+ const entry = renderCache.get(key);
833
+ if (entry) {
834
+ renderCacheBytes -= entry.totalBytes;
835
+ renderCache.delete(key);
836
+ }
837
+ }
838
+
839
+ function lruGet(map, key) {
840
+ const val = map.get(key);
841
+ if (val !== undefined) {
842
+ map.delete(key);
843
+ map.set(key, val);
844
+ }
845
+ return val;
846
+ }
847
+
848
+ function lruSet(map, key, val, maxSize) {
849
+ map.delete(key);
850
+ if (map.size >= maxSize) {
851
+ map.delete(map.keys().next().value);
852
+ }
853
+ map.set(key, val);
854
+ }
855
+
856
+ const sidebarCss = `.memd-layout { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
857
+ .memd-sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 1rem; border-right: 1px solid ${t.line}; background: color-mix(in srgb, ${t.fg} 3%, ${t.bg}); box-sizing: border-box; }
858
+ .memd-sidebar ul { list-style: none; padding: 0; margin: 0; }
859
+ .memd-sidebar li { padding: 0.15rem 0; }
860
+ .memd-sidebar a { color: ${t.accent}; text-decoration: none; display: block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.9rem; }
861
+ .memd-sidebar a:hover { background: color-mix(in srgb, ${t.fg} 5%, ${t.bg}); }
862
+ .memd-sidebar a[aria-current="page"] { background: color-mix(in srgb, ${t.accent} 12%, ${t.bg}); }
863
+ .memd-content { max-width: 800px; padding: 2rem 1rem; }
864
+ body:has(.memd-layout) { max-width: none; margin: 0; padding: 0; }
865
+ .memd-hamburger { display: none; position: fixed; top: 0.5rem; left: 0.5rem; z-index: 10; background: color-mix(in srgb, ${t.fg} 8%, ${t.bg}); border: 1px solid ${t.line}; color: ${t.fg}; padding: 0.3rem 0.5rem; cursor: pointer; border-radius: 4px; font-size: 1.2rem; }
866
+ @media (max-width: 768px) {
867
+ .memd-layout { grid-template-columns: 1fr; }
868
+ .memd-sidebar { position: fixed; top: 0; left: 0; width: 260px; height: 100vh; z-index: 5; transform: translateX(-100%); transition: transform 0.2s; }
869
+ .memd-sidebar.memd-sidebar-open { transform: translateX(0); }
870
+ .memd-hamburger { display: block; }
871
+ .memd-content { padding-top: 3rem; }
872
+ }`;
873
+ function injectSidebar(html, sidebarHtml) {
874
+ html = html.replace('<!--memd:head-->', `<style>${sidebarCss}</style>`);
875
+ html = html.replace('<!--memd:content-->', `<button class="memd-hamburger" aria-label="Toggle sidebar">&#9776;</button><div class="memd-layout">${sidebarHtml}<main class="memd-content">`);
876
+ html = html.replace('<!--/memd:content-->', '</main></div>');
877
+ return html;
878
+ }
879
+
880
+ async function sendHtml(req, res, html, etag, hasSidebar = false, hasMermaid = false) {
881
+ if (etag && etagMatch(req.headers['if-none-match'], etag)) {
882
+ res.writeHead(304);
883
+ res.end();
884
+ return;
885
+ }
886
+ let scripts = '';
887
+ if (options.watch) {
888
+ scripts += 'new EventSource("/_memd/events").onmessage=function(){location.reload()};';
889
+ }
890
+ if (hasSidebar) {
891
+ scripts += `document.querySelector('.memd-hamburger').onclick=function(){document.querySelector('.memd-sidebar').classList.toggle('memd-sidebar-open')};`;
892
+ }
893
+ if (hasMermaid) {
894
+ scripts += mermaidModalScript;
895
+ }
896
+ let csp;
897
+ if (scripts) {
898
+ html = html.replace('<!--memd:scripts-->', `<script nonce="${sessionNonce}">${scripts}</script>`);
899
+ csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${sessionNonce}'; connect-src 'self'; img-src 'self' https:; font-src 'self'; frame-ancestors 'self';`;
900
+ } else {
901
+ html = html.replace('<!--memd:scripts-->', '');
902
+ csp = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' https:; font-src 'self'; frame-ancestors 'self';";
903
+ }
904
+ const acceptEncoding = req.headers['accept-encoding'] || '';
905
+ const useGzip = acceptEncoding.includes('gzip');
906
+ let body;
907
+ if (useGzip && etag) {
908
+ const cachedGzip = lruGet(gzipCache, etag);
909
+ if (cachedGzip) {
910
+ body = cachedGzip;
911
+ } else {
912
+ body = await gzipAsync(html);
913
+ lruSet(gzipCache, etag, body, GZIP_CACHE_MAX);
914
+ }
915
+ } else if (useGzip) {
916
+ body = await gzipAsync(html);
917
+ } else {
918
+ body = Buffer.from(html);
919
+ }
920
+ const headers = {
921
+ 'Content-Type': 'text/html; charset=utf-8',
922
+ 'Content-Security-Policy': csp,
923
+ 'Content-Length': body.length,
924
+ 'X-Content-Type-Options': 'nosniff',
925
+ 'Referrer-Policy': 'no-referrer',
926
+ 'Vary': 'Accept-Encoding',
927
+ ...(useGzip ? { 'Content-Encoding': 'gzip' } : {}),
928
+ ...(etag ? { 'ETag': etag, 'Cache-Control': 'no-cache' } : {}),
929
+ };
930
+ res.writeHead(200, headers);
931
+ if (req.method === 'HEAD') { res.end(); return; }
932
+ res.end(body);
933
+ }
934
+
935
+ async function getDirEntries(dirPath, dirMtimeMs) {
936
+ const cached = lruGet(dirEntryCache, dirPath);
937
+ if (cached && cached.mtimeMs === dirMtimeMs) {
938
+ return cached.entries;
939
+ }
940
+ const entries = await readDirResolved(baseDir, dirPath);
941
+ lruSet(dirEntryCache, dirPath, { entries, mtimeMs: dirMtimeMs }, DIR_ENTRY_CACHE_MAX);
942
+ return entries;
943
+ }
944
+
945
+ // Compute ETag for a .md file (includes dirMtimeMs for sidebar consistency).
946
+ // Used for early 304 checks before calling getRenderedHtml.
947
+ async function computeMdEtag(fileStat, mdPath) {
948
+ const dirPath = path.dirname(mdPath);
949
+ let dirStat;
950
+ try { dirStat = await fs.promises.stat(dirPath); } catch {}
951
+ const dirMtimeMs = dirStat?.mtimeMs ?? 0;
952
+ return `"${fileStat.mtimeMs}-${fileStat.size}-${dirMtimeMs}"`;
953
+ }
954
+
955
+ async function getRenderedHtml(mdPath, urlPath, fileStat) {
956
+ const stat = fileStat || await fs.promises.stat(mdPath);
957
+ if (stat.size > MD_MAX_SIZE) {
958
+ throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Maximum size is ${MD_MAX_SIZE / 1024 / 1024} MB.`);
959
+ }
960
+ const dirPath = path.dirname(mdPath);
961
+ let dirStat;
962
+ try { dirStat = await fs.promises.stat(dirPath); } catch {}
963
+ const dirMtimeMs = dirStat?.mtimeMs ?? 0;
964
+ // ETag includes dirMtimeMs because the response contains the sidebar, which lists
965
+ // directory entries. Any file addition/removal in the same directory changes the sidebar,
966
+ // so all sibling .md ETags must be invalidated. This lowers browser cache hit rate but
967
+ // ensures sidebar consistency. The server-side renderCache is NOT keyed by dirMtimeMs,
968
+ // so re-rendering is only triggered when the .md file itself changes.
969
+ const etag = `"${stat.mtimeMs}-${stat.size}-${dirMtimeMs}"`;
970
+
971
+ // Check render cache (rawHtml without sidebar)
972
+ const cached = renderCacheGet(mdPath);
973
+ let rawHtml;
974
+ let hasMermaid;
975
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
976
+ rawHtml = cached.rawHtml;
977
+ hasMermaid = cached.hasMermaid;
978
+ } else {
979
+ const inflightKey = `${mdPath}\0${stat.mtimeMs}-${stat.size}`;
980
+ if (inflight.has(inflightKey)) {
981
+ rawHtml = await inflight.get(inflightKey);
982
+ hasMermaid = rawHtml.includes('mermaid-diagram');
983
+ } else {
984
+ // TOCTOU gap: checkSymlinkEscape resolves the symlink target via realpath
985
+ // and verifies it is within baseDir, but between that check and the
986
+ // subsequent readFile an attacker with filesystem access could swap the
987
+ // symlink to point outside baseDir. Exploiting this requires:
988
+ // 1. write access to the served directory (to create/replace a symlink), AND
989
+ // 2. precise timing to win the race.
990
+ // This is acceptable for a dev server on a trusted filesystem.
991
+ if (await checkSymlinkEscape(baseDir, mdPath)) {
992
+ throw Object.assign(new Error('Symlink escape detected'), { statusCode: 403 });
993
+ }
994
+ const renderPromise = fs.promises.readFile(mdPath, 'utf-8')
995
+ .then(md => {
996
+ if (Buffer.byteLength(md) > MD_MAX_SIZE) {
997
+ throw new Error(`File too large. Maximum size is ${MD_MAX_SIZE / 1024 / 1024} MB.`);
998
+ }
999
+ return pool.render(md, diagramColors);
1000
+ });
1001
+ inflight.set(inflightKey, renderPromise);
1002
+ try {
1003
+ rawHtml = await renderPromise;
1004
+ hasMermaid = rawHtml.includes('mermaid-diagram');
1005
+ renderCacheSet(mdPath, {
1006
+ rawHtml,
1007
+ rawHtmlBytes: Buffer.byteLength(rawHtml),
1008
+ mtimeMs: stat.mtimeMs,
1009
+ size: stat.size,
1010
+ hasMermaid,
1011
+ });
1012
+ } finally {
1013
+ inflight.delete(inflightKey);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ // Build sidebar from dir entries. Sidebar HTML is cached per (dirPath, dirMtimeMs, activeFile).
1019
+ let html = rawHtml;
1020
+ const hasSidebar = !!dirStat;
1021
+ if (hasSidebar) {
1022
+ const entries = await getDirEntries(dirPath, dirMtimeMs);
1023
+ const activeFile = path.basename(mdPath);
1024
+ const dirUrlPath = urlPath.endsWith('/') ? urlPath : urlPath.replace(/\/[^/]*$/, '/');
1025
+ const sidebarKey = `${dirPath}\0${dirMtimeMs}\0${activeFile}`;
1026
+ const cachedSidebar = lruGet(sidebarHtmlCache, sidebarKey);
1027
+ let sidebarHtml;
1028
+ if (cachedSidebar) {
1029
+ sidebarHtml = cachedSidebar;
1030
+ } else {
1031
+ sidebarHtml = buildSidebarHtml(dirUrlPath, activeFile, entries);
1032
+ lruSet(sidebarHtmlCache, sidebarKey, sidebarHtml, SIDEBAR_CACHE_MAX);
1033
+ }
1034
+ html = injectSidebar(rawHtml, sidebarHtml);
1035
+ }
1036
+ return { html, etag, hasSidebar, hasMermaid };
1037
+ }
1038
+
1039
+ const server = createServer(async (req, res) => {
1040
+ res.on('finish', () => {
1041
+ console.log(`${req.method} ${req.url} ${res.statusCode}`);
1042
+ });
1043
+
1044
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1045
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
1046
+ res.end('Method Not Allowed');
1047
+ return;
1048
+ }
1049
+
1050
+ try {
1051
+ const parsedUrl = new URL(req.url, 'http://localhost');
1052
+ const urlPath = parsedUrl.pathname;
1053
+
1054
+ if (options.watch && urlPath === '/_memd/events' && req.method === 'GET') {
1055
+ if (sseClients.size >= 100) {
1056
+ res.writeHead(503, { 'Content-Type': 'text/plain' });
1057
+ res.end('Too many SSE connections');
1058
+ return;
1059
+ }
1060
+ res.writeHead(200, {
1061
+ 'Content-Type': 'text/event-stream',
1062
+ 'Cache-Control': 'no-cache',
1063
+ 'Connection': 'keep-alive',
1064
+ });
1065
+ res.write(':\n\n');
1066
+ sseClients.add(res);
1067
+ req.on('close', () => sseClients.delete(res));
1068
+ return;
1069
+ }
1070
+
1071
+ if (isDotPath(urlPath)) {
1072
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
1073
+ res.end('Forbidden');
1074
+ return;
1075
+ }
1076
+ const fsPath = resolveServePath(baseDir, urlPath);
1077
+ if (!fsPath) {
1078
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
1079
+ res.end('Forbidden');
1080
+ return;
1081
+ }
1082
+ let stat;
1083
+ try { stat = await fs.promises.stat(fsPath); } catch (e) {
1084
+ if (e.code !== 'ENOENT') console.error(`stat error: ${fsPath} - ${e.message}`);
1085
+ }
1086
+
1087
+ // TOCTOU gap: the symlink check (checkSymlinkEscape) and the subsequent
1088
+ // file read (createReadStream / getRenderedHtml) are not atomic. Between the
1089
+ // two operations, a symlink could be replaced to point outside baseDir.
1090
+ // Exploiting this requires write access to the served directory and precise
1091
+ // timing. Acceptable for a dev server on a trusted filesystem.
1092
+ if (stat && !fsPath.endsWith('.md')) {
1093
+ if (await checkSymlinkEscape(baseDir, fsPath)) {
1094
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
1095
+ res.end('Forbidden');
1096
+ return;
1097
+ }
1098
+ }
1099
+
1100
+ // Directory -> index.md or listing
1101
+ if (stat?.isDirectory()) {
1102
+ if (!urlPath.endsWith('/')) {
1103
+ res.writeHead(302, { Location: urlPath + '/' + parsedUrl.search });
1104
+ res.end();
1105
+ return;
1106
+ }
1107
+ const indexPath = path.join(fsPath, 'index.md');
1108
+ let indexStat;
1109
+ try { indexStat = await fs.promises.stat(indexPath); } catch {}
1110
+ if (indexStat?.isFile()) {
1111
+ const etag = await computeMdEtag(indexStat, indexPath);
1112
+ if (etagMatch(req.headers['if-none-match'], etag)) {
1113
+ res.writeHead(304);
1114
+ res.end();
1115
+ return;
1116
+ }
1117
+ const result = await getRenderedHtml(indexPath, urlPath, indexStat);
1118
+ await sendHtml(req, res, result.html, result.etag, result.hasSidebar, result.hasMermaid);
1119
+ return;
1120
+ }
1121
+ const dirEtag = `"dir-${stat.mtimeMs}"`;
1122
+ if (etagMatch(req.headers['if-none-match'], dirEtag)) {
1123
+ res.writeHead(304);
1124
+ res.end();
1125
+ return;
1126
+ }
1127
+ const dirCached = lruGet(dirListCache, fsPath);
1128
+ if (dirCached && dirCached.mtimeMs === stat.mtimeMs) {
1129
+ await sendHtml(req, res, dirCached.html, dirEtag);
1130
+ return;
1131
+ }
1132
+ const resolved = await readDirResolved(baseDir, fsPath);
1133
+ const html = renderDirectoryListing(urlPath, resolved, diagramColors);
1134
+ lruSet(dirListCache, fsPath, { html, mtimeMs: stat.mtimeMs }, DIR_LIST_CACHE_MAX);
1135
+ await sendHtml(req, res, html, dirEtag);
1136
+ return;
1137
+ }
1138
+
1139
+ // .md file -> render as HTML
1140
+ if (stat?.isFile() && fsPath.endsWith('.md')) {
1141
+ const etag = await computeMdEtag(stat, fsPath);
1142
+ if (etagMatch(req.headers['if-none-match'], etag)) {
1143
+ res.writeHead(304);
1144
+ res.end();
1145
+ return;
1146
+ }
1147
+ const result = await getRenderedHtml(fsPath, urlPath, stat);
1148
+ await sendHtml(req, res, result.html, result.etag, result.hasSidebar, result.hasMermaid);
1149
+ return;
1150
+ }
1151
+
1152
+ // Extensionless -> try .md fallback
1153
+ if (!stat) {
1154
+ const mdSafe = resolveServePath(baseDir, urlPath + '.md');
1155
+ if (mdSafe) {
1156
+ let mdStat;
1157
+ try { mdStat = await fs.promises.stat(mdSafe); } catch {}
1158
+ if (mdStat?.isFile()) {
1159
+ const etag = await computeMdEtag(mdStat, mdSafe);
1160
+ if (etagMatch(req.headers['if-none-match'], etag)) {
1161
+ res.writeHead(304);
1162
+ res.end();
1163
+ return;
1164
+ }
1165
+ const result = await getRenderedHtml(mdSafe, urlPath, mdStat);
1166
+ await sendHtml(req, res, result.html, result.etag, result.hasSidebar, result.hasMermaid);
1167
+ return;
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ // Static file -> serve with allowed MIME types
1173
+ if (stat?.isFile()) {
1174
+ const ext = path.extname(fsPath).toLowerCase();
1175
+ const mime = STATIC_MIME[ext];
1176
+ if (mime) {
1177
+ const etag = `"${stat.mtimeMs}-${stat.size}"`;
1178
+ if (etagMatch(req.headers['if-none-match'], etag)) {
1179
+ res.writeHead(304);
1180
+ res.end();
1181
+ return;
1182
+ }
1183
+ const staticHeaders = {
1184
+ 'Content-Type': mime,
1185
+ 'Content-Length': stat.size,
1186
+ 'Cache-Control': 'no-cache',
1187
+ 'ETag': etag,
1188
+ 'X-Content-Type-Options': 'nosniff',
1189
+ 'Referrer-Policy': 'no-referrer',
1190
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' https:; font-src 'self'; frame-ancestors 'self';",
1191
+ };
1192
+ // SVG can contain <script>; force download and sandbox to prevent XSS
1193
+ if (ext === '.svg') {
1194
+ staticHeaders['Content-Disposition'] = 'attachment';
1195
+ staticHeaders['Content-Security-Policy'] = "default-src 'none'; sandbox;";
1196
+ }
1197
+ res.writeHead(200, staticHeaders);
1198
+ if (req.method === 'HEAD') { res.end(); return; }
1199
+ const stream = fs.createReadStream(fsPath);
1200
+ stream.on('error', (err) => { console.error(`Stream error: ${fsPath} - ${err.message}`); if (!res.writableEnded) res.end(); });
1201
+ stream.pipe(res);
1202
+ return;
1203
+ }
1204
+ }
1205
+
1206
+ // 404
1207
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1208
+ res.end('Not Found');
1209
+ } catch (err) {
1210
+ console.error(`Error: ${req.method} ${req.url} - ${err.message}`);
1211
+ if (!res.headersSent) {
1212
+ const status = err.statusCode || 500;
1213
+ res.writeHead(status, { 'Content-Type': 'text/plain' });
1214
+ res.end(status === 403 ? 'Forbidden' : 'Internal Server Error');
1215
+ } else if (!res.writableEnded) {
1216
+ res.end();
1217
+ }
1218
+ }
1219
+ });
1220
+
1221
+ server.requestTimeout = 60_000;
1222
+ server.headersTimeout = 10_000;
1223
+
1224
+ server.on('error', (err) => {
1225
+ if (err.code === 'EADDRINUSE') {
1226
+ console.error(`Port ${options.port} is already in use`);
1227
+ } else {
1228
+ console.error(`Server error: ${err.message}`);
1229
+ }
1230
+ process.exit(1);
1231
+ });
1232
+
1233
+ let watcher;
1234
+ if (options.watch) {
1235
+ let watchDebounce;
1236
+ try {
1237
+ // On Linux, recursive fs.watch relies on inotify; large directory trees may hit
1238
+ // /proc/sys/fs/inotify/max_user_watches and silently stop watching new paths.
1239
+ // When the limit is reached, new watches may fail without emitting an 'error' event,
1240
+ // causing a "silent failure" where "Watch: enabled" is displayed but some files are
1241
+ // not monitored. There is no reliable cross-platform way to detect this at runtime
1242
+ // (reading /proc/sys/fs/inotify/max_user_watches is Linux-specific and the actual
1243
+ // count of consumed watches is not exposed). Manual refresh works as a fallback.
1244
+ watcher = fs.watch(baseDir, { recursive: true }, (eventType, filename) => {
1245
+ if (!filename) return;
1246
+ if (filename.split(path.sep).some(seg => seg === '..')) return;
1247
+ const fullPath = path.resolve(path.join(baseDir, filename));
1248
+ if (!isPathWithinBase(baseDir, fullPath)) return;
1249
+ // Only .md changes explicitly invalidate caches; non-.md changes still trigger
1250
+ // SSE reload but rely on natural mtime checks for cache freshness.
1251
+ if (filename.endsWith('.md')) {
1252
+ renderCacheDelete(fullPath);
1253
+ const parentDir = path.dirname(fullPath);
1254
+ dirListCache.delete(parentDir);
1255
+ dirEntryCache.delete(parentDir);
1256
+ // Sidebar cache entries for this directory are keyed by dirMtimeMs,
1257
+ // so stale entries are never served. LRU eviction handles cleanup.
1258
+ }
1259
+ clearTimeout(watchDebounce);
1260
+ watchDebounce = setTimeout(() => {
1261
+ for (const client of sseClients) {
1262
+ client.write('data: reload\n\n');
1263
+ }
1264
+ }, 100);
1265
+ });
1266
+ watcher.on('error', (err) => {
1267
+ console.error(`Watch error: ${err.message}`);
1268
+ });
1269
+ } catch (err) {
1270
+ console.error(`Warning: --watch failed to start (${err.message}). Live-reload disabled.`);
1271
+ }
1272
+ }
1273
+
1274
+ server.listen(options.port, options.host, () => {
1275
+ const addr = server.address();
1276
+ let displayHost = options.host === '0.0.0.0' || options.host === '::' ? 'localhost' : options.host;
1277
+ if (displayHost.includes(':')) displayHost = `[${displayHost}]`;
1278
+ console.log(`memd serve`);
1279
+ console.log(` Directory: ${baseDir}`);
1280
+ console.log(` Theme: ${options.theme}`);
1281
+ if (options.watch) console.log(' Watch: enabled');
1282
+ console.log(` URL: http://${displayHost}:${addr.port}/`);
1283
+ if (options.host !== '127.0.0.1' && options.host !== 'localhost' && options.host !== '::1') {
1284
+ console.log(' WARNING: Server is exposed to the network. No authentication is enabled.');
1285
+ }
1286
+ });
1287
+
1288
+ let shuttingDown = false;
1289
+ for (const signal of ['SIGTERM', 'SIGINT']) {
1290
+ process.on(signal, () => {
1291
+ if (shuttingDown) return;
1292
+ shuttingDown = true;
1293
+ if (watcher) watcher.close();
1294
+ for (const client of sseClients) client.end();
1295
+ server.close(() => {
1296
+ pool.terminate();
1297
+ process.exit(0);
1298
+ });
1299
+ setTimeout(() => {
1300
+ pool.terminate();
1301
+ server.closeAllConnections();
1302
+ process.exit(0);
1303
+ }, 3000);
1304
+ });
1305
+ }
1306
+ });
1307
+
503
1308
  program.parse();
504
1309
  }
505
1310