@helping-ai-workflow/md2doc 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +225 -17
- package/lib/editor/cli.js +50 -1
- package/lib/editor/client.js +5376 -173
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/server.js +398 -6
- package/lib/editor/toolbar-model.js +118 -20
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +874 -32
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
package/lib/editor/server.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const drawioLib = require('../drawio.js');
|
|
5
7
|
const { renderMarkdown } = require('../md2doc.js');
|
|
6
8
|
|
|
7
9
|
const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
|
|
@@ -54,6 +56,50 @@ const TURNDOWN_SRC = fs.readFileSync(
|
|
|
54
56
|
require.resolve('turndown/lib/turndown.browser.umd.js'), 'utf8');
|
|
55
57
|
const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8');
|
|
56
58
|
|
|
59
|
+
// v3.4.0 batch3 Task 6: the wavedrom GUI's four modules.
|
|
60
|
+
//
|
|
61
|
+
// wave-codec.js / wave-geometry.js / wave-store.js are plain CommonJS ON
|
|
62
|
+
// PURPOSE, and rewriting them into the UMD header every other module here uses
|
|
63
|
+
// is not available: test/wave-codec.test.js bans the spelling `require(` in the
|
|
64
|
+
// codec outright, test/wave-geometry.test.js bans every host-object read a UMD
|
|
65
|
+
// header performs (`typeof self`, `root.x = …`), and test/wave-store.test.js
|
|
66
|
+
// pins the store's require list to exactly `["require('./wave-codec.js')"]`. So
|
|
67
|
+
// the browser gets them through the small CommonJS shim below instead —
|
|
68
|
+
// the module text itself is inlined byte for byte.
|
|
69
|
+
//
|
|
70
|
+
// wave-ui.js is UMD like its neighbours and needs no shim; in the page it
|
|
71
|
+
// defines `window.md2docWaveUi` and takes the other three as parameters.
|
|
72
|
+
const WAVE_CODEC_SRC = fs.readFileSync(path.join(__dirname, 'wave-codec.js'), 'utf8');
|
|
73
|
+
const WAVE_GEOMETRY_SRC = fs.readFileSync(path.join(__dirname, 'wave-geometry.js'), 'utf8');
|
|
74
|
+
const WAVE_STORE_SRC = fs.readFileSync(path.join(__dirname, 'wave-store.js'), 'utf8');
|
|
75
|
+
const WAVE_UI_SRC = fs.readFileSync(path.join(__dirname, 'wave-ui.js'), 'utf8');
|
|
76
|
+
|
|
77
|
+
// One CommonJS module, wrapped so a plain <script> can carry it.
|
|
78
|
+
//
|
|
79
|
+
// The `'use strict'` is the FIRST statement of the script, not of the wrapper
|
|
80
|
+
// function, so the whole tag — module body included — is strict. That is
|
|
81
|
+
// load-bearing rather than tidy: the store deep-freezes every document it
|
|
82
|
+
// keeps, and a frozen object only BITES in strict mode. test/wave-store.test.js
|
|
83
|
+
// T24 measured the sloppy-mode alternative — four of five writes to a frozen
|
|
84
|
+
// document were silent — so a wrapper that lost the prologue would put the
|
|
85
|
+
// original corruption back and make it quieter.
|
|
86
|
+
//
|
|
87
|
+
// `require` here resolves by basename against what has already been shimmed,
|
|
88
|
+
// which is enough for the only two edges that exist (both './wave-codec.js')
|
|
89
|
+
// and stays a total function for anything else: an unknown name answers
|
|
90
|
+
// undefined and the module that asked fails where it asked.
|
|
91
|
+
function waveModuleTag(name, src) {
|
|
92
|
+
return '<script>\'use strict\';\n' +
|
|
93
|
+
'window.__md2docWave = window.__md2docWave || {};\n' +
|
|
94
|
+
'(function () {\n' +
|
|
95
|
+
'var module = { exports: {} };\n' +
|
|
96
|
+
'var exports = module.exports;\n' +
|
|
97
|
+
'function require(p) { return window.__md2docWave[String(p).split(\'/\').pop()]; }\n' +
|
|
98
|
+
src + '\n' +
|
|
99
|
+
'window.__md2docWave[' + JSON.stringify(name) + '] = module.exports;\n' +
|
|
100
|
+
'})();</script>\n';
|
|
101
|
+
}
|
|
102
|
+
|
|
57
103
|
// The SAME pure module the page gets, required here for the node side of
|
|
58
104
|
// /api/asset. asset.js is deliberately free of `require('fs')`/`require('path')`
|
|
59
105
|
// (it is plain string arithmetic), so every filesystem decision below is made
|
|
@@ -145,15 +191,290 @@ function assertBlockRangesFit(blocks, lines) {
|
|
|
145
191
|
}
|
|
146
192
|
}
|
|
147
193
|
|
|
148
|
-
|
|
194
|
+
// F13: the never-opened grace deadline is deliberately its own constant,
|
|
195
|
+
// not idleTimeoutMs reused. idleTimeoutMs governs the gap since the last
|
|
196
|
+
// browser ping (client.js pings every 10s; the default 30,000ms tolerates
|
|
197
|
+
// a short run of missed pings once a real tab is confirmed present).
|
|
198
|
+
// Before the URL is opened there is no ping to measure against — the wait
|
|
199
|
+
// is instead for a human to alt-tab to a browser, or, over SSH, copy the
|
|
200
|
+
// printed URL across to a local machine and load it there, which routinely
|
|
201
|
+
// takes longer than idleTimeoutMs allows. Reusing idleTimeoutMs for that
|
|
202
|
+
// wait would close sessions on people still reading the terminal output.
|
|
203
|
+
// Five minutes is chosen as a margin generous for that human handoff while
|
|
204
|
+
// still finite; test/cli-edit.test.js drives it through a shortened
|
|
205
|
+
// override — an unopened session still exits on its own, and a session
|
|
206
|
+
// opened partway through the grace window keeps working afterward.
|
|
207
|
+
const DEFAULT_NEVER_OPENED_GRACE_MS = 5 * 60 * 1000;
|
|
208
|
+
|
|
209
|
+
// T21 item 3: closing this server has to mean "stop serving now", not "stop
|
|
210
|
+
// serving once every open socket happens to go away". `server.close()` on its
|
|
211
|
+
// own does the latter: it stops accepting, then waits for the connections it
|
|
212
|
+
// already has. A browser handed the edit URL opens a socket it may never send
|
|
213
|
+
// a request on (a speculative preconnect), and such a socket is not one Node
|
|
214
|
+
// retires by itself — driven against `md2doc --edit` on this box with a
|
|
215
|
+
// viewer stub that opened one TCP connection to the port and sent no bytes,
|
|
216
|
+
// SIGINT printed cli.js's line and the process was still alive 10 s later,
|
|
217
|
+
// 3 runs of 3 — while the same runs with a viewer stub that connected and
|
|
218
|
+
// sent a request, and with `--no-open`, exited in 7-10 ms. The same hang
|
|
219
|
+
// reaches the idle and never-opened deadlines below, which have no second
|
|
220
|
+
// keystroke to rescue them, so every exit this file owns goes through here.
|
|
221
|
+
//
|
|
222
|
+
// closeAllConnections() lands the sockets that close() would wait on. It is
|
|
223
|
+
// Node >= 18.2 and package.json's engines floor is >= 18, so the call is
|
|
224
|
+
// guarded rather than assumed; on an 18.0/18.1 runtime the behaviour is
|
|
225
|
+
// exactly what it was before this function existed.
|
|
226
|
+
function shutdownServer(server, reason) {
|
|
227
|
+
// F13: name the reason before closing, so cli.js's listener (the process's
|
|
228
|
+
// stdout writer) can print a line distinct from the other exits.
|
|
229
|
+
if (reason) server.emit('md2doc-auto-close', reason);
|
|
230
|
+
server.close();
|
|
231
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function createEditorServer({
|
|
235
|
+
files, idleTimeoutMs = 30000, neverOpenedGraceMs = DEFAULT_NEVER_OPENED_GRACE_MS,
|
|
236
|
+
clientJs = '', listenPort = 0,
|
|
237
|
+
}) {
|
|
149
238
|
const absFiles = files.map((f) => path.resolve(f));
|
|
150
239
|
let idleTimer = null;
|
|
240
|
+
let neverOpenedTimer = null;
|
|
151
241
|
let started = false;
|
|
152
242
|
|
|
243
|
+
// v3.4.0 batch2 Task 6 — Ruling B2-2: watch every `.drawio`/`.xml` file the
|
|
244
|
+
// CURRENTLY OPEN document references, and let /api/ping's already-firing
|
|
245
|
+
// 10s heartbeat (see its own comment below) double as the staleness check,
|
|
246
|
+
// rather than inventing a push transport server.js has never had (no
|
|
247
|
+
// WebSocket/SSE/fs.watch anywhere in this stack — see this task's
|
|
248
|
+
// investigation note).
|
|
249
|
+
//
|
|
250
|
+
// TWO maps, and the split is the whole point of fix round 1:
|
|
251
|
+
//
|
|
252
|
+
// drawioWatch fileId -> { refs }. WHAT THE LAST BAKE RESOLVED.
|
|
253
|
+
// The list of absolute paths that render actually resolved,
|
|
254
|
+
// i.e. the only paths worth stat'ing for this document.
|
|
255
|
+
// Fix round 2 (re-review G13): this used to carry a
|
|
256
|
+
// `stamps` Map as well, which nothing ever read — the new
|
|
257
|
+
// tab's baseline comes from trackDrawioRefs()'s RETURN
|
|
258
|
+
// value, handed straight to registerDrawioClient() in the
|
|
259
|
+
// same expression. Dead state in a freshly written
|
|
260
|
+
// mechanism is a maintenance trap, so it is gone; the stamp
|
|
261
|
+
// lib/md2doc.js takes before the bake (review F4) still
|
|
262
|
+
// travels, just not through here.
|
|
263
|
+
//
|
|
264
|
+
// drawioClients clientKey -> { fileId, stamps, pending, lastSeen }.
|
|
265
|
+
// WHAT ONE BROWSER TAB IS ACTUALLY SHOWING. Review F3: the
|
|
266
|
+
// baseline used to be per-file, so with two tabs open on one
|
|
267
|
+
// document the first to ping consumed the one signal and the
|
|
268
|
+
// second sat on a stale diagram looking perfectly healthy.
|
|
269
|
+
// A tab gets its id minted by GET /edit/:id — the same
|
|
270
|
+
// response that paints its DOM — so its baseline is exactly
|
|
271
|
+
// the bytes it is displaying, with no window in which
|
|
272
|
+
// another tab's render could seed it wrong.
|
|
273
|
+
//
|
|
274
|
+
// Review F2's invariant: a client's `stamps` advances ONLY when that client
|
|
275
|
+
// has told us its DOM now shows those bytes. Until then every heartbeat
|
|
276
|
+
// keeps reporting the same staleness, so every one of the client's bail
|
|
277
|
+
// paths (network error, mid-gesture DOM, focus inside the block, …) really
|
|
278
|
+
// does get retried on the next tick instead of losing the change forever.
|
|
279
|
+
const drawioWatch = new Map();
|
|
280
|
+
const drawioClients = new Map();
|
|
281
|
+
// The token that identifies a CONTENT VERSION, not a ping. Minted once per
|
|
282
|
+
// (client, observed stamp set) and repeated verbatim on every later ping
|
|
283
|
+
// that still sees the same bytes — if it were per-ping, a refresh that took
|
|
284
|
+
// longer than one 10s beat would always ack a token the server had already
|
|
285
|
+
// superseded, and the pair would never converge.
|
|
286
|
+
let drawioTokenSeq = 0;
|
|
287
|
+
let drawioLastSweep = 0;
|
|
288
|
+
// 10 minutes = 60 missed heartbeats. A tab that has not pinged in that long
|
|
289
|
+
// is closed (or its machine slept); keeping its entry would leak one small
|
|
290
|
+
// object per tab ever opened for the life of the server process.
|
|
291
|
+
const DRAWIO_CLIENT_IDLE_MS = 10 * 60 * 1000;
|
|
292
|
+
|
|
293
|
+
function drawioClientKey(fileId, clientId) {
|
|
294
|
+
if (typeof clientId !== 'string' || clientId === '') return null;
|
|
295
|
+
if (typeof fileId !== 'number' || !isFinite(fileId)) return null;
|
|
296
|
+
return String(fileId) + '\u0000' + clientId;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sweepDrawioClients(now) {
|
|
300
|
+
if (now - drawioLastSweep < 60 * 1000) return;
|
|
301
|
+
drawioLastSweep = now;
|
|
302
|
+
for (const [k, c] of drawioClients) {
|
|
303
|
+
if (now - c.lastSeen > DRAWIO_CLIENT_IDLE_MS) drawioClients.delete(k);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Fix round 2 (re-review G5). A tab that is still talking to us is alive by
|
|
308
|
+
// definition, so a well-formed clientId with no entry gets one back rather
|
|
309
|
+
// than being answered 204 forever with no signal and no route back but a
|
|
310
|
+
// reload. Reachable the moment a laptop sleeps or a background tab's timers
|
|
311
|
+
// are frozen for longer than the sweep window — and before this, the tab's
|
|
312
|
+
// own returning heartbeat was what deleted it (the sweep ran BEFORE the
|
|
313
|
+
// lastSeen refresh), so the failure mode was silent, permanent, and caused
|
|
314
|
+
// by the recovery attempt itself.
|
|
315
|
+
//
|
|
316
|
+
// Seeded with an EMPTY stamp map on purpose, not with the last bake's: we
|
|
317
|
+
// do not know what that tab's DOM is showing, and an empty baseline makes
|
|
318
|
+
// the next check report stale, which costs one re-render the client then
|
|
319
|
+
// acks. Wrong in the safe direction (one redundant bake) instead of the
|
|
320
|
+
// unsafe one (a tab that silently never updates again).
|
|
321
|
+
//
|
|
322
|
+
// Fix round 3 (re-review2 H4): the bound is EVICT-OLDEST, applied at BOTH
|
|
323
|
+
// doors. Round 2 capped only the ping door and refused past the cap, which
|
|
324
|
+
// was wrong twice over: `GET /edit/:id` wrote into the same map with no
|
|
325
|
+
// check at all, and refusing meant that once 64 entries accumulated, a
|
|
326
|
+
// genuinely swept live tab could never re-register — G5's own guard rail
|
|
327
|
+
// reintroducing exactly the silent deafness G5 was raised about. Dropping
|
|
328
|
+
// the least-recently-seen entry instead keeps the map at <= MAX from both
|
|
329
|
+
// doors AND never turns a live tab away: the entry we drop is by
|
|
330
|
+
// construction the one that has gone quietest.
|
|
331
|
+
//
|
|
332
|
+
// WHAT THIS BOUNDS, EXACTLY (fix round 4, re-review3 M3 — the sentence this
|
|
333
|
+
// replaces claimed more than the code delivers): **the map, not the render
|
|
334
|
+
// load.** Round 2's refuse-at-cap did cap the bakes a fresh-id-per-ping
|
|
335
|
+
// caller could provoke — past 64 ids it answered a bare 204, so no token and
|
|
336
|
+
// no `/api/render` followed. Evict-oldest removes that ceiling on purpose:
|
|
337
|
+
// a caller rotating a new id every ping now gets `{stale, token}` every
|
|
338
|
+
// time and can drive `/api/render` — and therefore headless-Chromium bakes
|
|
339
|
+
// — without bound. That trade is deliberate, because the refusal it removes
|
|
340
|
+
// is exactly what made a swept live tab permanently deaf, and availability
|
|
341
|
+
// for the real tab is worth more here than a ceiling on a caller that is
|
|
342
|
+
// already inside the loopback-only, Host-checked front door (see the
|
|
343
|
+
// DNS-rebinding note above `hostIsLoopback`). Recorded as a known
|
|
344
|
+
// limitation rather than guarded: every guard added to this mechanism so
|
|
345
|
+
// far has produced the next round's finding.
|
|
346
|
+
const DRAWIO_CLIENT_MAX = 64;
|
|
347
|
+
function makeRoomForDrawioClient() {
|
|
348
|
+
while (drawioClients.size >= DRAWIO_CLIENT_MAX) {
|
|
349
|
+
let oldestKey = null, oldestSeen = Infinity;
|
|
350
|
+
for (const [k, c] of drawioClients) {
|
|
351
|
+
if (c.lastSeen < oldestSeen) { oldestSeen = c.lastSeen; oldestKey = k; }
|
|
352
|
+
}
|
|
353
|
+
if (oldestKey === null) return; // unreachable while size > 0
|
|
354
|
+
drawioClients.delete(oldestKey);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function ensureDrawioClient(fileId, clientId, now) {
|
|
358
|
+
const key = drawioClientKey(fileId, clientId);
|
|
359
|
+
if (key === null) return null;
|
|
360
|
+
let c = drawioClients.get(key);
|
|
361
|
+
if (c) { c.lastSeen = now; return c; }
|
|
362
|
+
if (!drawioWatch.has(fileId)) return null;
|
|
363
|
+
makeRoomForDrawioClient();
|
|
364
|
+
c = { fileId, stamps: new Map(), pending: null, lastSeen: now };
|
|
365
|
+
drawioClients.set(key, c);
|
|
366
|
+
return c;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Called after every render that actually re-reads the source markdown
|
|
370
|
+
// (GET /edit/:id and POST /api/render both call renderMarkdown() fresh).
|
|
371
|
+
// Records WHAT WAS BAKED; deliberately does NOT touch any client's
|
|
372
|
+
// baseline — that is review F2's invariant, and the trap F2 names is
|
|
373
|
+
// exactly this function: /api/render runs BEFORE the client has decided
|
|
374
|
+
// whether it can apply the result, so advancing anything here would lose
|
|
375
|
+
// every post-response bail.
|
|
376
|
+
function trackDrawioRefs(fileId, refs) {
|
|
377
|
+
const list = Array.isArray(refs) ? refs : [];
|
|
378
|
+
const paths = [];
|
|
379
|
+
const stamps = new Map();
|
|
380
|
+
for (const r of list) {
|
|
381
|
+
const abs = r && typeof r.path === 'string' ? r.path : null;
|
|
382
|
+
if (!abs || stamps.has(abs)) continue;
|
|
383
|
+
paths.push(abs);
|
|
384
|
+
stamps.set(abs, r.stamp === undefined ? null : r.stamp);
|
|
385
|
+
}
|
|
386
|
+
drawioWatch.set(fileId, { refs: paths });
|
|
387
|
+
return stamps;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function registerDrawioClient(fileId, stamps) {
|
|
391
|
+
const clientId = crypto.randomBytes(12).toString('hex');
|
|
392
|
+
const key = drawioClientKey(fileId, clientId);
|
|
393
|
+
if (key === null) return clientId; // unreachable today; never key on null
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
makeRoomForDrawioClient(); // H4: this door is bounded too
|
|
396
|
+
drawioClients.set(key, {
|
|
397
|
+
fileId, stamps: new Map(stamps), pending: null, lastSeen: now,
|
|
398
|
+
});
|
|
399
|
+
sweepDrawioClients(now); // G5: after the write, never before
|
|
400
|
+
return clientId;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function sameStamps(a, b) {
|
|
404
|
+
if (a.size !== b.size) return false;
|
|
405
|
+
for (const [k, v] of a) { if (!b.has(k) || b.get(k) !== v) return false; }
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// (a) requirement: a document with no drawio references must cost this
|
|
410
|
+
// heartbeat NOTHING beyond the Map lookups below — zero fs.statSync calls,
|
|
411
|
+
// same as the bare 204 this replaces. Only a document that actually has
|
|
412
|
+
// `refs.length > 0` pays for the stat loop that follows.
|
|
413
|
+
//
|
|
414
|
+
// Returns the token to hand the client, or null for "nothing stale".
|
|
415
|
+
function checkDrawioStale(fileId, clientId) {
|
|
416
|
+
const state = drawioWatch.get(fileId);
|
|
417
|
+
if (!state || state.refs.length === 0) return null;
|
|
418
|
+
const key = drawioClientKey(fileId, clientId);
|
|
419
|
+
if (key === null) return null;
|
|
420
|
+
const c = drawioClients.get(key);
|
|
421
|
+
// An unknown client here is one touchDrawioClient() declined to
|
|
422
|
+
// (re-)register: a page from before this feature (no clientId at all), a
|
|
423
|
+
// malformed id, or a fileId this server never rendered. It gets the old
|
|
424
|
+
// bare 204 rather than a signal it has no baseline to interpret. Note
|
|
425
|
+
// that hitting DRAWIO_CLIENT_MAX is NOT one of those cases any more —
|
|
426
|
+
// see makeRoomForDrawioClient() (re-review2 H4).
|
|
427
|
+
if (!c) return null;
|
|
428
|
+
const now = new Map();
|
|
429
|
+
let changed = false;
|
|
430
|
+
for (const p of state.refs) {
|
|
431
|
+
const st = drawioLib.drawioStampOf(p);
|
|
432
|
+
now.set(p, st);
|
|
433
|
+
if (c.stamps.get(p) !== st) changed = true;
|
|
434
|
+
}
|
|
435
|
+
if (!changed) { c.pending = null; return null; }
|
|
436
|
+
if (c.pending && sameStamps(c.pending.stamps, now)) return c.pending.id;
|
|
437
|
+
drawioTokenSeq += 1;
|
|
438
|
+
c.pending = { id: 'd' + drawioTokenSeq, stamps: now };
|
|
439
|
+
return c.pending.id;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Keeps a live tab out of the idle sweep above (re-registering it if the
|
|
443
|
+
// sweep already got there — G5), and is the one place the sweep is actually
|
|
444
|
+
// driven from: the heartbeat is the only thing that fires often enough to
|
|
445
|
+
// be worth hanging it on, and it is throttled to at most once a minute
|
|
446
|
+
// inside sweepDrawioClients().
|
|
447
|
+
//
|
|
448
|
+
// ORDER IS LOAD-BEARING: ensure/touch first, sweep second. The other way
|
|
449
|
+
// round, a tab returning from a >10-minute freeze is deleted by the very
|
|
450
|
+
// ping it sent to say it was back.
|
|
451
|
+
function touchDrawioClient(fileId, clientId) {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
ensureDrawioClient(fileId, clientId, now);
|
|
454
|
+
sweepDrawioClients(now);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The client's half of F2's invariant: it sends back the token it was given
|
|
458
|
+
// ONLY after its DOM really carries that render. Anything less than a
|
|
459
|
+
// complete apply (a block skipped because the user's focus was inside it,
|
|
460
|
+
// a mid-gesture DOM, a failed fetch) sends nothing, so `stamps` stays put
|
|
461
|
+
// and the very next heartbeat reports the same staleness again.
|
|
462
|
+
function ackDrawioApplied(fileId, clientId, token) {
|
|
463
|
+
if (typeof token !== 'string' || token === '') return;
|
|
464
|
+
const key = drawioClientKey(fileId, clientId);
|
|
465
|
+
if (key === null) return;
|
|
466
|
+
const c = drawioClients.get(key);
|
|
467
|
+
if (!c || !c.pending || c.pending.id !== token) return;
|
|
468
|
+
c.stamps = c.pending.stamps;
|
|
469
|
+
c.pending = null;
|
|
470
|
+
}
|
|
471
|
+
|
|
153
472
|
function bumpIdle(server) {
|
|
154
473
|
if (!started) return;
|
|
155
474
|
if (idleTimer) clearTimeout(idleTimer);
|
|
156
|
-
idleTimer = setTimeout(() =>
|
|
475
|
+
idleTimer = setTimeout(() => {
|
|
476
|
+
shutdownServer(server, 'idle');
|
|
477
|
+
}, idleTimeoutMs);
|
|
157
478
|
if (idleTimer.unref) idleTimer.unref();
|
|
158
479
|
}
|
|
159
480
|
|
|
@@ -235,11 +556,15 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
235
556
|
const eol = (crlfCount > bareLf && crlfCount > bareCr) ? '\r\n'
|
|
236
557
|
: (bareCr > bareLf && bareCr > crlfCount) ? '\r'
|
|
237
558
|
: '\n';
|
|
238
|
-
const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
|
|
559
|
+
const { html, blocks, drawioRefs } = await renderMarkdown(mdText, file, { editMode: true });
|
|
560
|
+
// Fix round 1 (review F3): mint this TAB's own staleness baseline from
|
|
561
|
+
// the very render that is about to paint it. Per-tab, not per-file —
|
|
562
|
+
// two tabs on one document must each get told about an external edit.
|
|
563
|
+
const drawioClientId = registerDrawioClient(fileId, trackDrawioRefs(fileId, drawioRefs));
|
|
239
564
|
const lines = mdText.split(/\r\n|\r|\n/);
|
|
240
565
|
assertBlockRangesFit(blocks, lines);
|
|
241
566
|
const payload = JSON.stringify({
|
|
242
|
-
fileId, mtimeMs, eol, lines, blocks,
|
|
567
|
+
fileId, mtimeMs, eol, lines, blocks, drawioClientId,
|
|
243
568
|
});
|
|
244
569
|
const inject =
|
|
245
570
|
`<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
|
|
@@ -257,6 +582,10 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
257
582
|
`<script>${ASSET_SRC}</script>\n` +
|
|
258
583
|
`<script>${TURNDOWN_SRC}</script>\n` +
|
|
259
584
|
`<script>${PASTE_MD_SRC}</script>\n` +
|
|
585
|
+
waveModuleTag('wave-codec.js', WAVE_CODEC_SRC) +
|
|
586
|
+
waveModuleTag('wave-geometry.js', WAVE_GEOMETRY_SRC) +
|
|
587
|
+
waveModuleTag('wave-store.js', WAVE_STORE_SRC) +
|
|
588
|
+
`<script>${WAVE_UI_SRC}</script>\n` +
|
|
260
589
|
`<script>${clientJs}</script>\n`;
|
|
261
590
|
// Splice at the LAST "</body>" — the document's real closing tag.
|
|
262
591
|
// The first occurrence can sit inside an inlined diagram bundle's JS
|
|
@@ -283,7 +612,10 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
283
612
|
const { fileId, content } = await readJson(req);
|
|
284
613
|
const file = absFiles[fileId];
|
|
285
614
|
if (!file) return send(res, 404, { error: 'unknown file' });
|
|
286
|
-
const { parts, blocks } = await renderMarkdown(content, file, { editMode: true });
|
|
615
|
+
const { parts, blocks, drawioRefs } = await renderMarkdown(content, file, { editMode: true });
|
|
616
|
+
// Refs only — NOT any client's baseline. See trackDrawioRefs()'s own
|
|
617
|
+
// comment: the client has not yet decided whether it can apply this.
|
|
618
|
+
trackDrawioRefs(fileId, drawioRefs);
|
|
287
619
|
bumpIdle(server);
|
|
288
620
|
return send(res, 200, { parts, blocks });
|
|
289
621
|
}
|
|
@@ -412,6 +744,40 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
412
744
|
|
|
413
745
|
if (req.method === 'POST' && url.pathname === '/api/ping') {
|
|
414
746
|
bumpIdle(server);
|
|
747
|
+
// v3.4.0 batch2 Task 6: fileId / drawioClientId / drawioAck are NEW,
|
|
748
|
+
// all optional, on the body this heartbeat already sent every 10s
|
|
749
|
+
// (old clients / the existing test in test/editor-server.test.js send
|
|
750
|
+
// `{}` — that still resolves to "no tracked state", the same
|
|
751
|
+
// zero-cost 204 as before). A body that fails to parse degrades the
|
|
752
|
+
// same way: no staleness check, plain 204 — a malformed ping body
|
|
753
|
+
// must never be the thing that breaks the idle-keepalive contract.
|
|
754
|
+
//
|
|
755
|
+
// Fix round 1 (review F8): an explicit 8 KB readJson limit, not the
|
|
756
|
+
// 50 MB default — the same precedent /api/save set for its own body,
|
|
757
|
+
// for the same reason. This body is always three short scalars, and
|
|
758
|
+
// this is the ONE route that fires unconditionally every 10 seconds
|
|
759
|
+
// from every open tab; it has no business also being the most
|
|
760
|
+
// permissive one in the server.
|
|
761
|
+
let fileId, drawioClientId, drawioAck;
|
|
762
|
+
try {
|
|
763
|
+
const body = await readJson(req, 8 * 1024);
|
|
764
|
+
if (body) {
|
|
765
|
+
fileId = body.fileId;
|
|
766
|
+
drawioClientId = body.drawioClientId;
|
|
767
|
+
drawioAck = body.drawioAck;
|
|
768
|
+
}
|
|
769
|
+
} catch (e) {
|
|
770
|
+
fileId = undefined; drawioClientId = undefined; drawioAck = undefined;
|
|
771
|
+
}
|
|
772
|
+
// Ack FIRST, then re-check: a client that has just confirmed it
|
|
773
|
+
// applied version N must get a 204 on this same beat unless the file
|
|
774
|
+
// has moved on past N since.
|
|
775
|
+
touchDrawioClient(fileId, drawioClientId);
|
|
776
|
+
ackDrawioApplied(fileId, drawioClientId, drawioAck);
|
|
777
|
+
const staleToken = checkDrawioStale(fileId, drawioClientId);
|
|
778
|
+
if (staleToken) {
|
|
779
|
+
return send(res, 200, { stale: true, token: staleToken });
|
|
780
|
+
}
|
|
415
781
|
res.writeHead(204);
|
|
416
782
|
return res.end();
|
|
417
783
|
}
|
|
@@ -442,6 +808,28 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
442
808
|
server.listen(listenPort, '127.0.0.1');
|
|
443
809
|
});
|
|
444
810
|
const port = server.address().port;
|
|
811
|
+
|
|
812
|
+
// F13: counts from listen (this point), not from process start — listen
|
|
813
|
+
// is the moment a client could first reach the server, and that is the
|
|
814
|
+
// deadline this timer is measuring against. Unref'd like idleTimer, and
|
|
815
|
+
// for the same reason: the listening socket is what should keep the
|
|
816
|
+
// process alive, not this timer. A timer that held a ref would keep the
|
|
817
|
+
// process up on its own even if the socket already went away, which is
|
|
818
|
+
// backwards for a clock whose only job is to give up and exit.
|
|
819
|
+
neverOpenedTimer = setTimeout(() => {
|
|
820
|
+
// The guard against closing a session that has already been opened —
|
|
821
|
+
// this timer is armed once, at listen time, and left to run; it is
|
|
822
|
+
// not cancelled by the GET /edit/:id handler. Whether the URL was
|
|
823
|
+
// opened by the time the deadline arrives is decided here, by reading
|
|
824
|
+
// `started` fresh at fire time. Proven load-bearing by mutation: with
|
|
825
|
+
// this line removed, an actively-pinged session still gets closed the
|
|
826
|
+
// moment this deadline passes (see test/cli-edit.test.js's "must
|
|
827
|
+
// survive the never-opened grace deadline" case).
|
|
828
|
+
if (started) return;
|
|
829
|
+
shutdownServer(server, 'never-opened');
|
|
830
|
+
}, neverOpenedGraceMs);
|
|
831
|
+
if (neverOpenedTimer.unref) neverOpenedTimer.unref();
|
|
832
|
+
|
|
445
833
|
return {
|
|
446
834
|
server,
|
|
447
835
|
port,
|
|
@@ -449,7 +837,11 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
449
837
|
const i = absFiles.indexOf(path.resolve(absPath));
|
|
450
838
|
return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
|
|
451
839
|
},
|
|
452
|
-
close() {
|
|
840
|
+
close() {
|
|
841
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
842
|
+
if (neverOpenedTimer) clearTimeout(neverOpenedTimer);
|
|
843
|
+
shutdownServer(server, null);
|
|
844
|
+
},
|
|
453
845
|
};
|
|
454
846
|
}
|
|
455
847
|
|