@helping-ai-workflow/md2doc 3.5.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 +2 -2
- package/lib/editor/client.js +82 -5
- package/lib/editor/server.js +225 -49
- package/lib/editor/wave-codec.js +160 -3
- package/lib/editor/wave-draw.js +1470 -0
- package/lib/editor/wave-geometry.js +469 -19
- package/lib/editor/wave-panels.js +1102 -0
- package/lib/editor/wave-store.js +36 -1
- package/lib/editor/wave-ui.js +1807 -1802
- package/lib/md2doc.js +520 -50
- package/package.json +2 -2
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,
|
|
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
|
-
...(
|
|
28
|
+
...(connectionGraceMs === undefined ? {} : { connectionGraceMs }),
|
|
29
29
|
...(neverOpenedGraceMs === undefined ? {} : { neverOpenedGraceMs }),
|
|
30
30
|
});
|
|
31
31
|
const urls = files.map((f) => srv.urlFor(f));
|
package/lib/editor/client.js
CHANGED
|
@@ -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
|
|
|
@@ -17542,7 +17580,8 @@
|
|
|
17542
17580
|
// An older cached page that predates the injection must degrade to "the
|
|
17543
17581
|
// button does nothing visible", never to a throw out of a click handler.
|
|
17544
17582
|
if (!ui || !mods || !mods['wave-store.js'] || !mods['wave-codec.js'] ||
|
|
17545
|
-
!mods['wave-geometry.js']
|
|
17583
|
+
!mods['wave-geometry.js'] || !mods['wave-draw.js'] ||
|
|
17584
|
+
!mods['wave-panels.js']) return;
|
|
17546
17585
|
if (waveEditor !== null) return;
|
|
17547
17586
|
hideWaveEditBtn();
|
|
17548
17587
|
|
|
@@ -17603,8 +17642,27 @@
|
|
|
17603
17642
|
document: document,
|
|
17604
17643
|
host: document.body,
|
|
17605
17644
|
source: body.text,
|
|
17645
|
+
// v3.6.0 Task 15: which block in this document the editor is over,
|
|
17646
|
+
// for the 匯出 filename (`exportName()` in wave-ui.js). The editor
|
|
17647
|
+
// cannot derive it — it is handed one block's TEXT and nothing about
|
|
17648
|
+
// where that text came from — and two diagrams in one document
|
|
17649
|
+
// exporting under the same filename is a silent overwrite in the
|
|
17650
|
+
// user's downloads folder.
|
|
17651
|
+
blockIndex: id,
|
|
17652
|
+
// …and the document's own name for the other half of that filename.
|
|
17653
|
+
// `baseTitle`, NOT `document.title`: this file owns the page title
|
|
17654
|
+
// while a session is open and writes the dirty marker into it
|
|
17655
|
+
// (`(documentIsDirty() ? '● ' : '') + baseTitle`, above). Handing over
|
|
17656
|
+
// the decorated title would land a user who has actually edited with
|
|
17657
|
+
// `●-doc-wave-1.svg`, and a user who has not with the clean name —
|
|
17658
|
+
// i.e. it would look right in exactly the case nobody exports in.
|
|
17659
|
+
// `baseTitle` is the title as it was before this file touched it,
|
|
17660
|
+
// which is md2doc's `path.basename(src, '.md')`.
|
|
17661
|
+
fileLabel: baseTitle,
|
|
17606
17662
|
codec: mods['wave-codec.js'],
|
|
17607
17663
|
geometry: mods['wave-geometry.js'],
|
|
17664
|
+
draw: mods['wave-draw.js'],
|
|
17665
|
+
panels: mods['wave-panels.js'],
|
|
17608
17666
|
createStore: mods['wave-store.js'].createStore,
|
|
17609
17667
|
// The store's own answer to「哪一條 lane 是哪一條」across one operation,
|
|
17610
17668
|
// handed over rather than re-derived: the editor's cell cursor and its
|
|
@@ -17760,10 +17818,29 @@
|
|
|
17760
17818
|
|
|
17761
17819
|
/** One refusal, recorded and raised. The only producer of `waveUnwrittenEdit`. */
|
|
17762
17820
|
function waveRefuseGesture(seam, reason) {
|
|
17763
|
-
// A refusal is not papered over and not rolled back.
|
|
17764
|
-
//
|
|
17765
|
-
//
|
|
17766
|
-
//
|
|
17821
|
+
// A refusal is not papered over and not rolled back. This raises it as a
|
|
17822
|
+
// banner naming the lines it could not write, and waits for the user to
|
|
17823
|
+
// acknowledge it.
|
|
17824
|
+
//
|
|
17825
|
+
// v3.6.0 Task 16 fix round 2 (N1) withdraws the reason that used to stand
|
|
17826
|
+
// here — "wave-ui has already said so on the status line, which is where
|
|
17827
|
+
// the user's eyes are, but a status line is not a decision". wave-ui does
|
|
17828
|
+
// write the reason there (`afterStoreMoved`'s
|
|
17829
|
+
// `這個改動沒辦法只改幾個位元組寫回去:…`), but that line is ONE
|
|
17830
|
+
// `role="status"` string and the next thing the same gesture says
|
|
17831
|
+
// replaces it. MEASURED on the journey fixture: the fourth Alt+↑ of the
|
|
17832
|
+
// Task 16 scenario refuses — "the value at [\"signal\"] is or contains an
|
|
17833
|
+
// object, so replacing it would re-serialise part of the document's
|
|
17834
|
+
// structure…" — and `moveSelectedLane`'s landing line
|
|
17835
|
+
// (`搬動的 lane 落在群組「bus」裡`) is written straight after it, so the
|
|
17836
|
+
// sentence the user is left looking at reads like success.
|
|
17837
|
+
//
|
|
17838
|
+
// That is a FAMILY, not one caller: `addLaneFromToolbar` and 複製 have
|
|
17839
|
+
// written over the same refusal since before Task 16, and the rail's drag
|
|
17840
|
+
// drop announces nothing at all. Re-ordering those says is behaviour, and
|
|
17841
|
+
// it is parked as a next-version follow-up rather than done here. So the
|
|
17842
|
+
// banner below is not a nicety layered on a status line that already said
|
|
17843
|
+
// it — it is the only part of the refusal that cannot be clobbered.
|
|
17767
17844
|
seam.unwritten = true;
|
|
17768
17845
|
waveUnwrittenEdit = true;
|
|
17769
17846
|
setDirty();
|
package/lib/editor/server.js
CHANGED
|
@@ -56,7 +56,7 @@ const TURNDOWN_SRC = fs.readFileSync(
|
|
|
56
56
|
require.resolve('turndown/lib/turndown.browser.umd.js'), 'utf8');
|
|
57
57
|
const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8');
|
|
58
58
|
|
|
59
|
-
// v3.4.0 batch3 Task 6: the wavedrom GUI's
|
|
59
|
+
// v3.4.0 batch3 Task 6: the wavedrom GUI's modules.
|
|
60
60
|
//
|
|
61
61
|
// wave-codec.js / wave-geometry.js / wave-store.js are plain CommonJS ON
|
|
62
62
|
// PURPOSE, and rewriting them into the UMD header every other module here uses
|
|
@@ -67,11 +67,28 @@ const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8'
|
|
|
67
67
|
// the browser gets them through the small CommonJS shim below instead —
|
|
68
68
|
// the module text itself is inlined byte for byte.
|
|
69
69
|
//
|
|
70
|
+
// v3.6.0 Task 1: wave-draw.js joins them, plain CommonJS too — a pure
|
|
71
|
+
// extraction out of wave-ui.js, not test-guarded like the other three, but
|
|
72
|
+
// needing the identical shim for a different reason: wave-ui.js itself is
|
|
73
|
+
// injected as a bare <script> with no `require()` of its own (see its own
|
|
74
|
+
// header comment), so it cannot resolve `require('./wave-draw.js')` in the
|
|
75
|
+
// browser any more than the page around it could. `createWaveEditor()` takes
|
|
76
|
+
// it in as `opts.draw` instead, the same way it already takes `opts.codec`
|
|
77
|
+
// and `opts.geometry`.
|
|
78
|
+
//
|
|
79
|
+
// v3.6.0 Task 2: wave-panels.js joins them for the identical reason —
|
|
80
|
+
// another pure extraction out of wave-ui.js (the toolbar, the right-hand
|
|
81
|
+
// panels and the lane rail this time), needing the same shim because
|
|
82
|
+
// wave-ui.js still cannot `require()` anything in the browser. Taken in as
|
|
83
|
+
// `opts.panels`.
|
|
84
|
+
//
|
|
70
85
|
// wave-ui.js is UMD like its neighbours and needs no shim; in the page it
|
|
71
|
-
// defines `window.md2docWaveUi` and takes the other
|
|
86
|
+
// defines `window.md2docWaveUi` and takes the other five as parameters.
|
|
72
87
|
const WAVE_CODEC_SRC = fs.readFileSync(path.join(__dirname, 'wave-codec.js'), 'utf8');
|
|
73
88
|
const WAVE_GEOMETRY_SRC = fs.readFileSync(path.join(__dirname, 'wave-geometry.js'), 'utf8');
|
|
74
89
|
const WAVE_STORE_SRC = fs.readFileSync(path.join(__dirname, 'wave-store.js'), 'utf8');
|
|
90
|
+
const WAVE_DRAW_SRC = fs.readFileSync(path.join(__dirname, 'wave-draw.js'), 'utf8');
|
|
91
|
+
const WAVE_PANELS_SRC = fs.readFileSync(path.join(__dirname, 'wave-panels.js'), 'utf8');
|
|
75
92
|
const WAVE_UI_SRC = fs.readFileSync(path.join(__dirname, 'wave-ui.js'), 'utf8');
|
|
76
93
|
|
|
77
94
|
// One CommonJS module, wrapped so a plain <script> can carry it.
|
|
@@ -191,21 +208,64 @@ function assertBlockRangesFit(blocks, lines) {
|
|
|
191
208
|
}
|
|
192
209
|
}
|
|
193
210
|
|
|
194
|
-
// F13: the never-opened grace deadline is deliberately its own constant,
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// Before the URL is opened there is no
|
|
199
|
-
// is instead for a human to alt-tab to a
|
|
200
|
-
// printed URL across to a local machine and
|
|
201
|
-
// takes longer than
|
|
202
|
-
// wait would close sessions on people
|
|
203
|
-
// Five minutes is chosen as a margin
|
|
204
|
-
// still finite; test/cli-edit.test.js
|
|
205
|
-
// override — an unopened session still exits
|
|
206
|
-
// opened partway through the grace window keeps
|
|
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.
|
|
207
225
|
const DEFAULT_NEVER_OPENED_GRACE_MS = 5 * 60 * 1000;
|
|
208
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
|
+
|
|
209
269
|
// T21 item 3: closing this server has to mean "stop serving now", not "stop
|
|
210
270
|
// serving once every open socket happens to go away". `server.close()` on its
|
|
211
271
|
// own does the latter: it stops accepting, then waits for the connections it
|
|
@@ -232,20 +292,43 @@ function shutdownServer(server, reason) {
|
|
|
232
292
|
}
|
|
233
293
|
|
|
234
294
|
async function createEditorServer({
|
|
235
|
-
files,
|
|
295
|
+
files, neverOpenedGraceMs = DEFAULT_NEVER_OPENED_GRACE_MS,
|
|
296
|
+
connectionGraceMs = DEFAULT_CONNECTION_GRACE_MS,
|
|
236
297
|
clientJs = '', listenPort = 0,
|
|
237
298
|
}) {
|
|
238
299
|
const absFiles = files.map((f) => path.resolve(f));
|
|
239
|
-
let idleTimer = null;
|
|
240
300
|
let neverOpenedTimer = null;
|
|
241
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;
|
|
242
318
|
|
|
243
319
|
// v3.4.0 batch2 Task 6 — Ruling B2-2: watch every `.drawio`/`.xml` file the
|
|
244
320
|
// CURRENTLY OPEN document references, and let /api/ping's already-firing
|
|
245
321
|
// 10s heartbeat (see its own comment below) double as the staleness check,
|
|
246
|
-
// rather than inventing a push transport
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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.
|
|
249
332
|
//
|
|
250
333
|
// TWO maps, and the split is the whole point of fix round 1:
|
|
251
334
|
//
|
|
@@ -469,13 +552,84 @@ async function createEditorServer({
|
|
|
469
552
|
c.pending = null;
|
|
470
553
|
}
|
|
471
554
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
+
});
|
|
479
633
|
}
|
|
480
634
|
|
|
481
635
|
const server = http.createServer(async (req, res) => {
|
|
@@ -585,6 +739,8 @@ async function createEditorServer({
|
|
|
585
739
|
waveModuleTag('wave-codec.js', WAVE_CODEC_SRC) +
|
|
586
740
|
waveModuleTag('wave-geometry.js', WAVE_GEOMETRY_SRC) +
|
|
587
741
|
waveModuleTag('wave-store.js', WAVE_STORE_SRC) +
|
|
742
|
+
waveModuleTag('wave-draw.js', WAVE_DRAW_SRC) +
|
|
743
|
+
waveModuleTag('wave-panels.js', WAVE_PANELS_SRC) +
|
|
588
744
|
`<script>${WAVE_UI_SRC}</script>\n` +
|
|
589
745
|
`<script>${clientJs}</script>\n`;
|
|
590
746
|
// Splice at the LAST "</body>" — the document's real closing tag.
|
|
@@ -596,8 +752,10 @@ async function createEditorServer({
|
|
|
596
752
|
const out = bodyAt !== -1
|
|
597
753
|
? html.slice(0, bodyAt) + inject + html.slice(bodyAt)
|
|
598
754
|
: html + inject;
|
|
599
|
-
started
|
|
600
|
-
|
|
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).
|
|
601
759
|
// no-store: the page embeds a snapshot of the client runtime AND the
|
|
602
760
|
// file's lines/mtime — a cached copy is stale code + a guaranteed
|
|
603
761
|
// mtime conflict after any external edit.
|
|
@@ -616,7 +774,6 @@ async function createEditorServer({
|
|
|
616
774
|
// Refs only — NOT any client's baseline. See trackDrawioRefs()'s own
|
|
617
775
|
// comment: the client has not yet decided whether it can apply this.
|
|
618
776
|
trackDrawioRefs(fileId, drawioRefs);
|
|
619
|
-
bumpIdle(server);
|
|
620
777
|
return send(res, 200, { parts, blocks });
|
|
621
778
|
}
|
|
622
779
|
|
|
@@ -637,7 +794,6 @@ async function createEditorServer({
|
|
|
637
794
|
const tmp = file + '.md2doc-tmp';
|
|
638
795
|
fs.writeFileSync(tmp, content, 'utf8');
|
|
639
796
|
fs.renameSync(tmp, file);
|
|
640
|
-
bumpIdle(server);
|
|
641
797
|
return send(res, 200, { mtimeMs: fs.statSync(file).mtimeMs });
|
|
642
798
|
}
|
|
643
799
|
|
|
@@ -772,19 +928,37 @@ async function createEditorServer({
|
|
|
772
928
|
}
|
|
773
929
|
throw e;
|
|
774
930
|
}
|
|
775
|
-
bumpIdle(server);
|
|
776
931
|
return send(res, 200, { name, path: assetLib.relPath(name) });
|
|
777
932
|
}
|
|
778
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
|
+
|
|
779
950
|
if (req.method === 'POST' && url.pathname === '/api/ping') {
|
|
780
|
-
bumpIdle(server);
|
|
781
951
|
// v3.4.0 batch2 Task 6: fileId / drawioClientId / drawioAck are NEW,
|
|
782
952
|
// all optional, on the body this heartbeat already sent every 10s
|
|
783
953
|
// (old clients / the existing test in test/editor-server.test.js send
|
|
784
954
|
// `{}` — that still resolves to "no tracked state", the same
|
|
785
955
|
// zero-cost 204 as before). A body that fails to parse degrades the
|
|
786
956
|
// same way: no staleness check, plain 204 — a malformed ping body
|
|
787
|
-
// must never be the thing that breaks the
|
|
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.
|
|
788
962
|
//
|
|
789
963
|
// Fix round 1 (review F8): an explicit 8 KB readJson limit, not the
|
|
790
964
|
// 50 MB default — the same precedent /api/save set for its own body,
|
|
@@ -845,22 +1019,25 @@ async function createEditorServer({
|
|
|
845
1019
|
|
|
846
1020
|
// F13: counts from listen (this point), not from process start — listen
|
|
847
1021
|
// is the moment a client could first reach the server, and that is the
|
|
848
|
-
// deadline this timer is measuring against. Unref'd like
|
|
849
|
-
// for the same reason: the listening socket
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
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.
|
|
853
1028
|
neverOpenedTimer = setTimeout(() => {
|
|
854
1029
|
// The guard against closing a session that has already been opened —
|
|
855
|
-
// this timer is armed once, at listen time, and left to run; it is
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
// `started` fresh at fire time
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
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).
|
|
862
1039
|
if (started) return;
|
|
863
|
-
|
|
1040
|
+
shutdownOnce(server, 'never-opened');
|
|
864
1041
|
}, neverOpenedGraceMs);
|
|
865
1042
|
if (neverOpenedTimer.unref) neverOpenedTimer.unref();
|
|
866
1043
|
|
|
@@ -872,9 +1049,8 @@ async function createEditorServer({
|
|
|
872
1049
|
return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
|
|
873
1050
|
},
|
|
874
1051
|
close() {
|
|
875
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
876
1052
|
if (neverOpenedTimer) clearTimeout(neverOpenedTimer);
|
|
877
|
-
|
|
1053
|
+
shutdownOnce(server, null);
|
|
878
1054
|
},
|
|
879
1055
|
};
|
|
880
1056
|
}
|