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/.claude/settings.local.json +5 -1
- package/README.md +39 -0
- package/main.js +901 -96
- package/package.json +1 -1
- package/render-shared.js +95 -0
- package/render-utils.js +31 -0
- package/render-worker.js +13 -0
- package/test/memd.test.js +293 -3
- package/test/pixel.png +0 -0
package/main.js
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-nocheck
|
|
3
|
-
import { marked
|
|
3
|
+
import { marked } from 'marked';
|
|
4
4
|
import { markedTerminal } from 'marked-terminal';
|
|
5
|
-
import { renderMermaidASCII,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
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.
|
|
282
|
-
a { color: ${t.accent}; }
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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">☰</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
|
|