@helping-ai-workflow/md2doc 3.6.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/editor/cli.js CHANGED
@@ -16,7 +16,7 @@ const AUTO_CLOSE_MESSAGES = {
16
16
  };
17
17
 
18
18
  async function startEditSession({
19
- files, port = null, open = true, quiet = false, idleTimeoutMs, neverOpenedGraceMs,
19
+ files, port = null, open = true, quiet = false, connectionGraceMs, neverOpenedGraceMs,
20
20
  }) {
21
21
  const srv = await createEditorServer({
22
22
  files,
@@ -25,7 +25,7 @@ async function startEditSession({
25
25
  // Included when the caller supplies a value; otherwise omitted so
26
26
  // createEditorServer's own default applies, rather than a duplicated
27
27
  // default here that could drift out of sync with it.
28
- ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
28
+ ...(connectionGraceMs === undefined ? {} : { connectionGraceMs }),
29
29
  ...(neverOpenedGraceMs === undefined ? {} : { neverOpenedGraceMs }),
30
30
  });
31
31
  const urls = files.map((f) => srv.urlFor(f));
@@ -17310,6 +17310,44 @@
17310
17310
  }).catch(() => {});
17311
17311
  }, 10000);
17312
17312
 
17313
+ // v3.6.0: session liveness no longer rides this 10s ping. Browsers throttle
17314
+ // a backgrounded (merely unfocused, tab still open) tab's timers — MEASURED
17315
+ // on this branch, Chrome's real background-timer throttling (not
17316
+ // puppeteer's automation defaults, which disable it) clamps this very
17317
+ // setInterval to roughly once per minute once the tab has been hidden a
17318
+ // while, well past the server's old 30s idle deadline. A connection is not
17319
+ // a timer, so it does not get throttled the same way: this stream stays
17320
+ // open for exactly as long as the tab does, and server.js (see GET
17321
+ // /api/alive's own comment) treats the stream closing — after a short
17322
+ // grace, to absorb an F5 reload's own close-then-reopen — as the tab
17323
+ // actually closing. No onmessage/onerror wiring is needed: server.js never
17324
+ // sends this tab anything meaningful to react to, the connection's mere
17325
+ // existence is the whole signal, and EventSource's own built-in reconnect
17326
+ // already covers a transient network blip without any code here.
17327
+ //
17328
+ // Opened IMMEDIATELY, deliberately — liveness protection starts at the
17329
+ // first possible moment, not after an arbitrary delay. An earlier version
17330
+ // of this delayed the open by 1500ms to dodge a test-harness interaction
17331
+ // (Puppeteer's `waitUntil: 'networkidle0'` never resolves once a
17332
+ // permanently-open connection exists — this whole document is ONE inlined
17333
+ // HTML response, so a fresh page normally goes network-idle well under a
17334
+ // second). That delay was the wrong fix: MEASURED on the very machine that
17335
+ // needed it, ordinary page loads went idle at 1027-1290ms, leaving only a
17336
+ // 210-473ms margin against the delay — one slow tick during page load and
17337
+ // the wait hangs for its full 30s timeout anyway, in up to 286 call sites
17338
+ // across the suite. The product should not contort its own liveness
17339
+ // window around a test harness's definition of idle; the suite's affected
17340
+ // `page.goto()`/`page.reload()` calls now wait on `networkidle2` instead
17341
+ // (tolerates the one permanent connection this page is SUPPOSED to hold —
17342
+ // see each touched test file for specifics), which is not a timing bet at
17343
+ // all.
17344
+ if (typeof EventSource === 'function') {
17345
+ try {
17346
+ new EventSource('/api/alive?fileId=' + encodeURIComponent(ED.fileId) +
17347
+ '&clientId=' + encodeURIComponent(ED.drawioClientId));
17348
+ } catch (e) { /* degrade to no liveness signal rather than throw */ }
17349
+ }
17350
+
17313
17351
  armEditables(contentEl);
17314
17352
  wireBlockSelection();
17315
17353
 
@@ -208,21 +208,64 @@ function assertBlockRangesFit(blocks, lines) {
208
208
  }
209
209
  }
210
210
 
211
- // F13: the never-opened grace deadline is deliberately its own constant,
212
- // not idleTimeoutMs reused. idleTimeoutMs governs the gap since the last
213
- // browser ping (client.js pings every 10s; the default 30,000ms tolerates
214
- // a short run of missed pings once a real tab is confirmed present).
215
- // Before the URL is opened there is no ping to measure against — the wait
216
- // is instead for a human to alt-tab to a browser, or, over SSH, copy the
217
- // printed URL across to a local machine and load it there, which routinely
218
- // takes longer than idleTimeoutMs allows. Reusing idleTimeoutMs for that
219
- // wait would close sessions on people still reading the terminal output.
220
- // Five minutes is chosen as a margin generous for that human handoff while
221
- // still finite; test/cli-edit.test.js drives it through a shortened
222
- // override — an unopened session still exits on its own, and a session
223
- // opened partway through the grace window keeps working afterward.
211
+ // F13: the never-opened grace deadline is deliberately its own constant, not
212
+ // connectionGraceMs (see that constant's own comment) reused. connectionGraceMs
213
+ // governs the gap between one /api/alive connection dropping and a
214
+ // replacement showing up once a real tab is confirmed to have existed at
215
+ // all. Before the URL is opened there is no connection to have dropped in
216
+ // the first place — the wait here is instead for a human to alt-tab to a
217
+ // browser, or, over SSH, copy the printed URL across to a local machine and
218
+ // load it there, which routinely takes longer than connectionGraceMs allows.
219
+ // Reusing connectionGraceMs for that wait would close sessions on people
220
+ // still reading the terminal output. Five minutes is chosen as a margin
221
+ // generous for that human handoff while still finite; test/cli-edit.test.js
222
+ // drives it through a shortened override — an unopened session still exits
223
+ // on its own, and a session opened partway through the grace window keeps
224
+ // working afterward.
224
225
  const DEFAULT_NEVER_OPENED_GRACE_MS = 5 * 60 * 1000;
225
226
 
227
+ // v3.6.0: replaces idleTimeoutMs as the "is anyone still there" clock.
228
+ // idleTimeoutMs measured the gap since the last /api/ping — a plain
229
+ // setInterval(..., 10000) in client.js — and pings from a backgrounded
230
+ // (merely unfocused, tab still open) browser tab are throttled by the
231
+ // browser itself. MEASURED on this branch (see LIVENESS-REPORT.md): once
232
+ // Chrome's real background-timer throttling is allowed to engage — puppeteer
233
+ // disables it by default for automation stability, and the first measurement
234
+ // pass on this branch silently measured THAT instead — a backgrounded tab's
235
+ // 10s ping settled at roughly once per minute. 60s > the old 30,000ms
236
+ // default, so a tab the user had only alt-tabbed away from looked exactly
237
+ // like a closed one and the session was killed out from under a document
238
+ // still open on screen. A connection is not a timer, so it is not subject to
239
+ // that clamp: GET /api/alive holds its response open for as long as the tab
240
+ // (or this same document's tab, after a reload) keeps it open, and dropping
241
+ // to zero open connections is what this grace measures from.
242
+ //
243
+ // The grace itself exists for one trap: an F5 reload closes the old
244
+ // connection and opens a new one, and the two are NOT the same event — there
245
+ // is a real (if small) gap between them, during which the connection count is
246
+ // genuinely zero. Without a grace, every reload would kill the session.
247
+ // MEASURED (LIVENESS-REPORT.md, five consecutive page.reload() calls against
248
+ // a real headless Chrome hitting the real /api/alive route): the old
249
+ // connection's 'close' to the replacement connection's arrival ran
250
+ // 55-73ms, averaging 64ms. This default (5s) is roughly 70x that measured
251
+ // margin — headroom for a slower disk, a loaded machine, or a proxy in the
252
+ // path — while still closing a genuinely abandoned session within a few
253
+ // seconds rather than lingering. test/cli-edit.test.js and
254
+ // test/editor-server.test.js drive it through a shortened override, the same
255
+ // pattern DEFAULT_NEVER_OPENED_GRACE_MS above already uses.
256
+ const DEFAULT_CONNECTION_GRACE_MS = 5000;
257
+
258
+ // server.js sends one of these on every open /api/alive stream so an idle
259
+ // stream is never silently dropped by an intermediary (a corporate proxy, a
260
+ // misbehaving localhost tool) that closes connections it hasn't seen traffic
261
+ // on in a while, and so a half-open socket (the far end vanished without a
262
+ // TCP FIN, e.g. a laptop that lost power) is discovered on the next write
263
+ // rather than held forever. A bare SSE comment line (no `data:` field) is
264
+ // invisible to EventSource's onmessage — nothing to parse, nothing to react
265
+ // to, exactly the "the connection's mere existence is the signal" contract
266
+ // client.js documents at its own call site.
267
+ const ALIVE_KEEPALIVE_MS = 15000;
268
+
226
269
  // T21 item 3: closing this server has to mean "stop serving now", not "stop
227
270
  // serving once every open socket happens to go away". `server.close()` on its
228
271
  // own does the latter: it stops accepting, then waits for the connections it
@@ -249,20 +292,43 @@ function shutdownServer(server, reason) {
249
292
  }
250
293
 
251
294
  async function createEditorServer({
252
- files, idleTimeoutMs = 30000, neverOpenedGraceMs = DEFAULT_NEVER_OPENED_GRACE_MS,
295
+ files, neverOpenedGraceMs = DEFAULT_NEVER_OPENED_GRACE_MS,
296
+ connectionGraceMs = DEFAULT_CONNECTION_GRACE_MS,
253
297
  clientJs = '', listenPort = 0,
254
298
  }) {
255
299
  const absFiles = files.map((f) => path.resolve(f));
256
- let idleTimer = null;
257
300
  let neverOpenedTimer = null;
258
301
  let started = false;
302
+ // v3.6.0: the liveness mechanism. `aliveConnections` holds every currently
303
+ // open GET /api/alive response, GLOBALLY across every tab and every file
304
+ // this one server serves — `md2doc --edit a.md b.md` is one session, and
305
+ // closing the tab for a.md must not tear down b.md's still-open tab, so
306
+ // the decision that matters is "is the set empty", never "which file was
307
+ // this connection for". `started` now means "at least one such connection
308
+ // has ever been registered" — see registerAliveConnection() and
309
+ // DEFAULT_NEVER_OPENED_GRACE_MS's own comment on why that boundary moved
310
+ // off GET /edit/:id.
311
+ const aliveConnections = new Set();
312
+ let shutdownGraceTimer = null;
313
+ // Guards against the (already-benign, but worth making impossible on
314
+ // purpose) case of two shutdown triggers landing close together — e.g. a
315
+ // connection's 'close' event arriving from closeAllConnections() itself
316
+ // while the process is already on its way out through srv.close().
317
+ let closing = false;
259
318
 
260
319
  // v3.4.0 batch2 Task 6 — Ruling B2-2: watch every `.drawio`/`.xml` file the
261
320
  // CURRENTLY OPEN document references, and let /api/ping's already-firing
262
321
  // 10s heartbeat (see its own comment below) double as the staleness check,
263
- // rather than inventing a push transport server.js has never had (no
264
- // WebSocket/SSE/fs.watch anywhere in this stack — see this task's
265
- // investigation note).
322
+ // rather than inventing a SEPARATE push transport for it. v3.6.0 DID add
323
+ // one push transport to this stack — GET /api/alive, an SSE stream, but
324
+ // only for session liveness (see registerAliveConnection() below and
325
+ // DEFAULT_CONNECTION_GRACE_MS's comment for why polling could not do that
326
+ // job). The reasoning that kept drawio staleness on polling is unchanged
327
+ // and still applies to drawio specifically: a filesystem watch would need
328
+ // one watcher per referenced file with no bound on how many a document can
329
+ // reference, and the existing 10s heartbeat already has a client on the
330
+ // other end of it for a different reason, so riding it costs nothing extra
331
+ // beyond the stat calls in checkDrawioStale() below.
266
332
  //
267
333
  // TWO maps, and the split is the whole point of fix round 1:
268
334
  //
@@ -486,13 +552,84 @@ async function createEditorServer({
486
552
  c.pending = null;
487
553
  }
488
554
 
489
- function bumpIdle(server) {
490
- if (!started) return;
491
- if (idleTimer) clearTimeout(idleTimer);
492
- idleTimer = setTimeout(() => {
493
- shutdownServer(server, 'idle');
494
- }, idleTimeoutMs);
495
- if (idleTimer.unref) idleTimer.unref();
555
+ // Fires shutdownServer() at most once, regardless of which of this file's
556
+ // several deadlines got there first (see `closing`'s own comment above).
557
+ function shutdownOnce(server, reason) {
558
+ if (closing) return;
559
+ closing = true;
560
+ cancelShutdownGrace();
561
+ shutdownServer(server, reason);
562
+ }
563
+
564
+ function cancelShutdownGrace() {
565
+ if (shutdownGraceTimer) { clearTimeout(shutdownGraceTimer); shutdownGraceTimer = null; }
566
+ }
567
+
568
+ // Armed the moment `aliveConnections` drops to zero, cancelled the moment
569
+ // a new one arrives (registerAliveConnection() below calls
570
+ // cancelShutdownGrace() unconditionally on every new connection, whether or
571
+ // not a grace timer happens to be running). This is the trap named in the
572
+ // design doc: an F5 reload closes the old /api/alive stream and opens a
573
+ // fresh one, and those are two SEPARATE events with a real gap between
574
+ // them — without this grace, every reload would read as "the tab closed"
575
+ // and kill the session out from under the user mid-refresh. See
576
+ // DEFAULT_CONNECTION_GRACE_MS's own comment for the measurement that sized
577
+ // the default.
578
+ function armShutdownGrace(server) {
579
+ if (shutdownGraceTimer || closing) return;
580
+ shutdownGraceTimer = setTimeout(() => {
581
+ shutdownGraceTimer = null;
582
+ // Re-check rather than trust the callback firing at all means "still
583
+ // zero": a connection could have registered and then, in a pathological
584
+ // fast sequence, this timer's callback could still be queued from
585
+ // before that registration cancelled it. Belt-and-braces; cheap.
586
+ if (aliveConnections.size > 0) return;
587
+ shutdownOnce(server, 'idle');
588
+ }, connectionGraceMs);
589
+ if (shutdownGraceTimer.unref) shutdownGraceTimer.unref();
590
+ }
591
+
592
+ // Registers one open GET /api/alive response as "this tab is here". Every
593
+ // call cancels any pending shutdown grace FIRST — a fresh connection is by
594
+ // definition proof the session should stay up, whether it is genuinely the
595
+ // first tab, a second tab, or a reload's replacement for one that just
596
+ // dropped. `started` flips here rather than on GET /edit/:id — see
597
+ // DEFAULT_NEVER_OPENED_GRACE_MS's comment on why the "was this session ever
598
+ // really opened" boundary is a live connection now, not the HTML request
599
+ // that precedes it by however long the browser takes to run client.js.
600
+ function registerAliveConnection(server, res, fileId, clientId) {
601
+ cancelShutdownGrace();
602
+ started = true;
603
+ // Stashed on the response object itself rather than threaded through a
604
+ // second Map: nothing in the closing decision reads these back (see this
605
+ // function's own comment), so a real diagnostic (a future `/api/debug`,
606
+ // a log line) is the only consumer, and it already has `res` in hand.
607
+ res._md2docAliveFileId = fileId;
608
+ res._md2docAliveClientId = clientId;
609
+ res.writeHead(200, {
610
+ 'content-type': 'text/event-stream; charset=utf-8',
611
+ 'cache-control': 'no-store',
612
+ connection: 'keep-alive',
613
+ });
614
+ // Flushes the headers immediately rather than waiting for the first
615
+ // keepalive tick — the client's EventSource does not consider the
616
+ // connection open (readyState transitions to OPEN) until bytes arrive.
617
+ res.write(': connected\n\n');
618
+ aliveConnections.add(res);
619
+ const keepalive = setInterval(() => {
620
+ // A write to an already-gone socket throws; the 'close' listener below
621
+ // is what actually tears this connection's bookkeeping down; this
622
+ // catch only stops that throw from reaching an uncaught spot on a beat
623
+ // that lost a race against the socket's own teardown.
624
+ try { res.write(': keepalive\n\n'); } catch (e) { /* 'close' handles cleanup */ }
625
+ }, ALIVE_KEEPALIVE_MS);
626
+ if (keepalive.unref) keepalive.unref();
627
+ res.on('close', () => {
628
+ clearInterval(keepalive);
629
+ if (!aliveConnections.has(res)) return; // already handled (e.g. shutdown path)
630
+ aliveConnections.delete(res);
631
+ if (aliveConnections.size === 0) armShutdownGrace(server);
632
+ });
496
633
  }
497
634
 
498
635
  const server = http.createServer(async (req, res) => {
@@ -615,8 +752,10 @@ async function createEditorServer({
615
752
  const out = bodyAt !== -1
616
753
  ? html.slice(0, bodyAt) + inject + html.slice(bodyAt)
617
754
  : html + inject;
618
- started = true;
619
- bumpIdle(server);
755
+ // `started` no longer flips here — see registerAliveConnection()'s
756
+ // comment. This request only proves the HTML was served, not that a
757
+ // live tab is behind it (this same GET is also what a plain HTTP
758
+ // client, e.g. curl or this file's own test helpers, sends).
620
759
  // no-store: the page embeds a snapshot of the client runtime AND the
621
760
  // file's lines/mtime — a cached copy is stale code + a guaranteed
622
761
  // mtime conflict after any external edit.
@@ -635,7 +774,6 @@ async function createEditorServer({
635
774
  // Refs only — NOT any client's baseline. See trackDrawioRefs()'s own
636
775
  // comment: the client has not yet decided whether it can apply this.
637
776
  trackDrawioRefs(fileId, drawioRefs);
638
- bumpIdle(server);
639
777
  return send(res, 200, { parts, blocks });
640
778
  }
641
779
 
@@ -656,7 +794,6 @@ async function createEditorServer({
656
794
  const tmp = file + '.md2doc-tmp';
657
795
  fs.writeFileSync(tmp, content, 'utf8');
658
796
  fs.renameSync(tmp, file);
659
- bumpIdle(server);
660
797
  return send(res, 200, { mtimeMs: fs.statSync(file).mtimeMs });
661
798
  }
662
799
 
@@ -791,19 +928,37 @@ async function createEditorServer({
791
928
  }
792
929
  throw e;
793
930
  }
794
- bumpIdle(server);
795
931
  return send(res, 200, { name, path: assetLib.relPath(name) });
796
932
  }
797
933
 
934
+ // v3.6.0: the liveness stream. A GET (EventSource cannot send any other
935
+ // method), held open for as long as the tab exists. fileId/clientId are
936
+ // read here purely so a future diagnostic has them to hand — see
937
+ // registerAliveConnection()'s own comment for why nothing about
938
+ // whether the session stays alive depends on either one being
939
+ // well-formed; the response object's own identity is the only thing
940
+ // the closing decision is keyed on, deliberately, the same way
941
+ // drawioClients above is keyed on a validated pair while THIS map does
942
+ // not need one at all.
943
+ if (req.method === 'GET' && url.pathname === '/api/alive') {
944
+ const aliveFileId = Number(url.searchParams.get('fileId'));
945
+ const aliveClientId = url.searchParams.get('clientId');
946
+ registerAliveConnection(server, res, aliveFileId, aliveClientId);
947
+ return; // response intentionally stays open — nothing more to send
948
+ }
949
+
798
950
  if (req.method === 'POST' && url.pathname === '/api/ping') {
799
- bumpIdle(server);
800
951
  // v3.4.0 batch2 Task 6: fileId / drawioClientId / drawioAck are NEW,
801
952
  // all optional, on the body this heartbeat already sent every 10s
802
953
  // (old clients / the existing test in test/editor-server.test.js send
803
954
  // `{}` — that still resolves to "no tracked state", the same
804
955
  // zero-cost 204 as before). A body that fails to parse degrades the
805
956
  // same way: no staleness check, plain 204 — a malformed ping body
806
- // must never be the thing that breaks the idle-keepalive contract.
957
+ // must never be the thing that breaks the drawio-staleness contract.
958
+ // v3.6.0: this route no longer has any bearing on whether the
959
+ // session stays alive — see GET /api/alive above for that. It is
960
+ // kept exactly as it was for the ONE job it still does: the drawio
961
+ // staleness heartbeat below.
807
962
  //
808
963
  // Fix round 1 (review F8): an explicit 8 KB readJson limit, not the
809
964
  // 50 MB default — the same precedent /api/save set for its own body,
@@ -864,22 +1019,25 @@ async function createEditorServer({
864
1019
 
865
1020
  // F13: counts from listen (this point), not from process start — listen
866
1021
  // is the moment a client could first reach the server, and that is the
867
- // deadline this timer is measuring against. Unref'd like idleTimer, and
868
- // for the same reason: the listening socket is what should keep the
869
- // process alive, not this timer. A timer that held a ref would keep the
870
- // process up on its own even if the socket already went away, which is
871
- // backwards for a clock whose only job is to give up and exit.
1022
+ // deadline this timer is measuring against. Unref'd like shutdownGraceTimer,
1023
+ // and for the same reason: the listening socket (or, once one exists, an
1024
+ // open /api/alive connection) is what should keep the process alive, not
1025
+ // this timer. A timer that held a ref would keep the process up on its own
1026
+ // even if the socket already went away, which is backwards for a clock
1027
+ // whose only job is to give up and exit.
872
1028
  neverOpenedTimer = setTimeout(() => {
873
1029
  // The guard against closing a session that has already been opened —
874
- // this timer is armed once, at listen time, and left to run; it is
875
- // not cancelled by the GET /edit/:id handler. Whether the URL was
876
- // opened by the time the deadline arrives is decided here, by reading
877
- // `started` fresh at fire time. Proven load-bearing by mutation: with
878
- // this line removed, an actively-pinged session still gets closed the
879
- // moment this deadline passes (see test/cli-edit.test.js's "must
880
- // survive the never-opened grace deadline" case).
1030
+ // this timer is armed once, at listen time, and left to run; it is not
1031
+ // cancelled by GET /api/alive registering a connection. Whether a live
1032
+ // connection was ever established by the time the deadline arrives is
1033
+ // decided here, by reading `started` fresh at fire time (registerAliveConnection()
1034
+ // is the only place that sets it — see its own comment). Proven
1035
+ // load-bearing by mutation: with this line removed, an actively-connected
1036
+ // session still gets closed the moment this deadline passes (see
1037
+ // test/cli-edit.test.js's "must survive the never-opened grace deadline"
1038
+ // case).
881
1039
  if (started) return;
882
- shutdownServer(server, 'never-opened');
1040
+ shutdownOnce(server, 'never-opened');
883
1041
  }, neverOpenedGraceMs);
884
1042
  if (neverOpenedTimer.unref) neverOpenedTimer.unref();
885
1043
 
@@ -891,9 +1049,8 @@ async function createEditorServer({
891
1049
  return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
892
1050
  },
893
1051
  close() {
894
- if (idleTimer) clearTimeout(idleTimer);
895
1052
  if (neverOpenedTimer) clearTimeout(neverOpenedTimer);
896
- shutdownServer(server, null);
1053
+ shutdownOnce(server, null);
897
1054
  },
898
1055
  };
899
1056
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.6.0",
3
+ "version": "3.6.1",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",