brookmd 0.28.0 → 0.29.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/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@ Notable changes to brookmd (formerly `flux-md`). Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.29.0 — 2026-07-31
8
+
9
+ **Sublinear resources.** Total parse work has an Ω(n) floor and stays exactly
10
+ there — but retained memory and reopen latency don't have to be O(n), and now
11
+ they aren't. Requires `brookmd-core` 0.26.0. Both features are **web-path**
12
+ (WASM + TS client); the native bindings (React Native / Kotlin / Swift /
13
+ Flutter) share every parser-core improvement but consume `allBlocks()` and so
14
+ keep full retention — see the platform matrix in the root README.
15
+
16
+ ### Added
17
+
18
+ - **Instant thread reopen — `getPersistable()` / `hydrate()` /
19
+ `sourceFingerprint()`.** A finished (or in-flight) stream can be persisted
20
+ as a small JSON envelope — the blocks `getSnapshot()` showed plus a source
21
+ length/hash and a `done` flag — and a fresh `BrookClient` hydrates from it
22
+ with **no worker, no WASM, no parsing**: 1 MB / 2,250 blocks re-streams in
23
+ ~110 ms, hydrates in **~5.5 ms** (and a real browser reopen also skips
24
+ worker spin-up and WASM init). To *continue* a non-finalized thread, pass
25
+ the original source to `beginResume()`: the UI keeps showing hydrated
26
+ blocks while a background re-parse catches up — reusing the existing
27
+ divergence/adoption machinery, so every hydrated block keeps its exact id
28
+ across the swap and nothing visible remounts; appends stream live once
29
+ caught up. The envelope is a versioned **package** format
30
+ (`hydrateVersion`), deliberately not the wire contract; the README's
31
+ "Instant thread reopen" section documents the persistence and invalidation
32
+ contract.
33
+ - **`retainCommittedHtml` — the parser no longer hoards the rendered
34
+ document.** The core kept every committed block's HTML alive for
35
+ `allBlocks()` — which the web worker path never calls. The worker now
36
+ defaults the flag **off**: retained memory beyond the source buffer drops
37
+ to the open tail only (measured: **53–70% less total retention** on 1 MB
38
+ streams, with the flag-off overhead above the raw source staying under
39
+ 300 bytes at every point). Patches are byte-identical either way — committed
40
+ blocks are emitted exactly once and never re-read. Rust-side default stays
41
+ ON, so native bindings and `allBlocks()` consumers are unaffected;
42
+ `renderToString` pins it on (it assembles from `allBlocks`). Set
43
+ `retainCommittedHtml: true` in `ParserConfig` if you drive the worker and
44
+ want `allBlocks` fidelity.
45
+
7
46
  ## 0.28.0 — 2026-07-31
8
47
 
9
48
  **Browser-side linearity.** The parser and wire have been O(new bytes) per
package/README.md CHANGED
@@ -586,6 +586,11 @@ class BrookClient {
586
586
  whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
587
587
  subscribe(listener: () => void): () => void; // React-friendly store
588
588
  getSnapshot(): Block[]; // ordered current blocks
589
+ getPersistable(source?: string): PersistableSnapshot; // capture the rendered doc as JSON
590
+ hydrate( // restore it: no worker, no parse
591
+ snapshot: PersistableSnapshot,
592
+ opts?: { source?: string }, // source ⇒ a live thread can resume
593
+ ): void;
589
594
  outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
590
595
  toPlaintext(): string; // rendered document as plain text (search / summaries)
591
596
  getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
@@ -1284,6 +1289,101 @@ WASM `BrookParser`, native bindings, C ABI) keep byte-identical v1 wire by
1284
1289
  default and can opt in with `setWireDelta(true)` — see
1285
1290
  [`WIRE.md` §11](https://github.com/siinghd/brookmd/blob/main/crates/brookmd-core/WIRE.md).
1286
1291
 
1292
+ ## Instant thread reopen — persist and hydrate
1293
+
1294
+ Reopening a long thread normally means re-feeding its whole source through the
1295
+ parser before the first paint: O(history) work between the click and the pixels,
1296
+ which is exactly when a chat UI feels frozen.
1297
+
1298
+ It is also unnecessary. A committed block is emitted **once** and is **final**,
1299
+ so the blocks a stream already produced are a complete description of the
1300
+ document — there is nothing to re-derive. Capture them, store them, restore them:
1301
+
1302
+ ```ts
1303
+ // When the thread closes (or at any checkpoint):
1304
+ await db.put(threadId, JSON.stringify(client.getPersistable()));
1305
+
1306
+ // When it reopens:
1307
+ const client = new BrookClient();
1308
+ client.hydrate(JSON.parse(await db.get(threadId))); // no worker, no WASM, no parse
1309
+ // <BrookMarkdown client={client} /> paints the whole thread on the first frame.
1310
+ ```
1311
+
1312
+ Measured on a 1 MB / ~2250-block thread: **~5 ms to hydrate vs ~110 ms to
1313
+ re-stream** it in 2 KB chunks (~51 ms even for a single-shot re-parse) — before
1314
+ counting the worker spin-up and WASM init a re-stream also pays. Hydration is
1315
+ O(blocks) of JSON handling; nothing parses.
1316
+
1317
+ Hydrated blocks are **ordinary committed blocks**: both renderers mount them
1318
+ through the same path as live ones, so a reopened thread renders byte-identical
1319
+ to the one the user closed.
1320
+
1321
+ ### What to store, and when to throw it away
1322
+
1323
+ ```ts
1324
+ interface PersistableSnapshot {
1325
+ hydrateVersion: number; // envelope version; hydrate() refuses what it can't read
1326
+ blocks: Block[]; // exactly what getSnapshot() showed
1327
+ sourceLength: number; // UTF-16 length of the markdown behind them
1328
+ sourceHash: string; // fingerprint of that markdown — a staleness check
1329
+ done: boolean; // had the stream been finalized?
1330
+ }
1331
+ ```
1332
+
1333
+ Store it as JSON beside the thread. **Invalidate it when your source moves on**:
1334
+ hydrate deliberately does *not* verify the blocks against the source, so a stale
1335
+ snapshot would paint stale HTML. That is what `sourceHash` is for:
1336
+
1337
+ ```ts
1338
+ import { sourceFingerprint } from "brookmd";
1339
+
1340
+ if (snapshot.sourceHash === sourceFingerprint(mySource)) {
1341
+ client.hydrate(snapshot); // instant
1342
+ } else {
1343
+ client.setContent(mySource, { done: true }); // the text changed — re-stream
1344
+ }
1345
+ ```
1346
+
1347
+ Discard a snapshot whose `hydrateVersion` your build doesn't read, too. Either
1348
+ way a failed hydrate is safe to fall back from: a malformed or unreadable
1349
+ snapshot is rejected **whole**, with nothing written until every block has
1350
+ validated.
1351
+
1352
+ > This envelope is a brookmd **package** format, *not* the parser wire contract
1353
+ > ([`WIRE.md`](https://github.com/siinghd/brookmd/blob/main/crates/brookmd-core/WIRE.md)).
1354
+ > The two are versioned independently.
1355
+
1356
+ ### Resuming a thread that was still streaming
1357
+
1358
+ A `done: false` snapshot was captured mid-stream. Pass the original markdown as
1359
+ `source` and the thread simply continues:
1360
+
1361
+ ```ts
1362
+ client.hydrate(snapshot, { source: markdownSoFar });
1363
+ client.append(nextDelta); // or client.pipeFrom(await fetch("/api/chat"))
1364
+ ```
1365
+
1366
+ The parser's internal state cannot be serialized (it is an `Rc` graph, not
1367
+ data), so continuing a document genuinely requires re-parsing what came before
1368
+ it — but that cost moves **off the critical path**. On the first `append`
1369
+ brookmd spins up the worker, re-feeds the source in the background, and keeps
1370
+ the hydrated blocks on screen throughout: the reader scrolls and reads while the
1371
+ parse catches up. New chunks need no buffering — they queue behind the re-feed
1372
+ and land the moment it completes (they arrive at model token speed; the parser
1373
+ catches up far faster). When the re-parse lands, unchanged blocks are adopted
1374
+ **by reference and by id**, so nothing re-renders and nothing remounts at the
1375
+ swap.
1376
+
1377
+ A `done: true` snapshot is terminal: it needs no `source`, no worker is ever
1378
+ created for it, and `append()` throws — a finished thread cannot be extended
1379
+ (`reset()` to start a new one). A `done: false` snapshot hydrated *without*
1380
+ `source` is view-only: it paints, but appending throws, because a document
1381
+ cannot be continued correctly without the text preceding it.
1382
+
1383
+ Nothing on the hydrate path touches a worker: `whenReady()` resolves immediately
1384
+ and `client.ready` is `true`, so a readiness gate never sits over a thread that
1385
+ is already on screen.
1386
+
1287
1387
  ## Security
1288
1388
 
1289
1389
  brookmd is XSS-safe by default — its HTML output is meant to be injected via
package/dist/client.d.ts CHANGED
@@ -26,6 +26,50 @@ export interface OutlineEntry {
26
26
  /** Stable block id — usable as a scroll target / React key. */
27
27
  id: number;
28
28
  }
29
+ /**
30
+ * A self-contained, JSON-serializable capture of everything a stream currently
31
+ * renders — produced by {@link BrookClient.getPersistable}, restored by
32
+ * {@link BrookClient.hydrate}.
33
+ *
34
+ * Reopening a long thread normally re-feeds its whole source through the parser
35
+ * before the first paint (O(source), and it blocks the reopen). Persist this
36
+ * next to the thread instead and restoring it is pure JSON handling: the blocks
37
+ * go straight into the store and both renderers mount them as ordinary
38
+ * committed blocks — **no worker, no WASM, no parse**.
39
+ *
40
+ * NOT the wire contract. The parser↔renderer wire (WIRE.md) is a separate,
41
+ * separately-versioned boundary; this envelope is a brookmd *package* format
42
+ * that only {@link BrookClient.hydrate} consumes.
43
+ */
44
+ export interface PersistableSnapshot {
45
+ /** Envelope version. {@link BrookClient.hydrate} refuses anything it does not
46
+ * know how to read, so a stored snapshot never half-restores. */
47
+ hydrateVersion: number;
48
+ /**
49
+ * The blocks exactly as {@link BrookClient.getSnapshot} showed them at
50
+ * capture: the committed history plus whatever tail was on screen. Plain
51
+ * JSON — a `Block` carries no hidden state (the renderers' splice bookkeeping
52
+ * lives in a WeakMap, never on the block), so `JSON.stringify` round-trips it.
53
+ */
54
+ blocks: Block[];
55
+ /** UTF-16 length of the markdown that produced `blocks`. */
56
+ sourceLength: number;
57
+ /** {@link sourceFingerprint} of that markdown. A staleness check — "is the
58
+ * source I stored still the one these blocks came from?" — and deliberately
59
+ * nothing more: not a checksum, not security. */
60
+ sourceHash: string;
61
+ /** Whether the captured stream had been finalized. A `done` snapshot is a
62
+ * terminal document: it needs no source and can never be resumed. */
63
+ done: boolean;
64
+ }
65
+ /**
66
+ * 32-bit FNV-1a over a string's UTF-16 code units, as 8 hex digits. Backs
67
+ * {@link PersistableSnapshot.sourceHash}: cheap enough to run over a megabyte
68
+ * of markdown, sharp enough to notice that a stored source changed. Exported so
69
+ * a caller can make the same staleness decision brookmd does —
70
+ * `snap.sourceHash === sourceFingerprint(mySource)`. Not a security primitive.
71
+ */
72
+ export declare function sourceFingerprint(source: string): string;
29
73
  export declare function applyPatch(store: BlockStore, patch: Patch): void;
30
74
  interface PoolWorker {
31
75
  worker: WorkerLike;
@@ -171,6 +215,7 @@ export declare class BrookClient {
171
215
  private recoveryBuffer;
172
216
  private recoveredLen;
173
217
  private recoveryAttempted;
218
+ private appendedAny;
174
219
  private coalesce;
175
220
  private rafHandle;
176
221
  private finalizePending;
@@ -182,6 +227,12 @@ export declare class BrookClient {
182
227
  /** Set by mergeStale when it had to compact a hole out of the view, so
183
228
  * getSnapshot skips caching a view whose indices no longer track `base`. */
184
229
  private mergeDropped;
230
+ private hydrated;
231
+ private hydratedDone;
232
+ private hydratedLength;
233
+ private hydratedHash;
234
+ private hydratedSource;
235
+ private resumed;
185
236
  private appendedBytes;
186
237
  private patchCount;
187
238
  private totalParseMicros;
@@ -261,6 +312,10 @@ export declare class BrookClient {
261
312
  */
262
313
  private settleRebind;
263
314
  get ready(): boolean;
315
+ /** True while a hydrated document has never been handed to a parser: the
316
+ * store holds restored blocks and no worker exists. Cleared once the resume
317
+ * re-feed is issued (or the caller re-drives the whole document itself). */
318
+ private get hydrationPending();
264
319
  /**
265
320
  * The fatal error that killed this client's worker, or `null` if healthy.
266
321
  *
@@ -329,6 +384,90 @@ export declare class BrookClient {
329
384
  setContent(content: string, opts?: {
330
385
  done?: boolean;
331
386
  }): void;
387
+ /**
388
+ * Capture everything this client currently renders as a plain-JSON
389
+ * {@link PersistableSnapshot}: `JSON.stringify` it, store it beside the
390
+ * thread, and {@link hydrate} it back later to repaint with **no parse at
391
+ * all**. This is the persistence half of instant thread reopen.
392
+ *
393
+ * The committed wire is already a complete serialization — a committed block
394
+ * is emitted exactly once and is final (WIRE.md §2) — so there is nothing to
395
+ * re-derive: the snapshot IS the document. Cost here is one pass to
396
+ * fingerprint the source; cost on the way back in is a `Map` fill.
397
+ *
398
+ * @param source the markdown driven into this stream, used ONLY to compute
399
+ * {@link PersistableSnapshot.sourceHash}. Optional, because a client with
400
+ * `recovery` on (the default) already retains it, as does a
401
+ * `setContent`-driven one; required in the single configuration that holds
402
+ * neither — `recovery: false` plus manual `append()` — where omitting it
403
+ * throws rather than persisting a snapshot no one can check for staleness.
404
+ */
405
+ getPersistable(source?: string): PersistableSnapshot;
406
+ /** The full driven document when this client happens to hold it: the recovery
407
+ * buffer is exactly that whenever `recovery` is on (the default), and
408
+ * setContent's baseline covers the recovery-off controlled-string mode.
409
+ * `null` means genuinely unknown — reachable only with recovery off AND
410
+ * manual appends. */
411
+ private retainedSource;
412
+ /**
413
+ * Restore a {@link PersistableSnapshot} into an untouched client. The blocks
414
+ * land in the store as ordinary committed blocks and `getSnapshot()` returns
415
+ * them immediately, so the first paint already has the whole document —
416
+ * **no worker is created, no WASM loads, nothing is parsed**. Reopening a
417
+ * thread costs O(blocks) of JSON handling instead of O(source) of parsing.
418
+ *
419
+ * Call it on a fresh client before anything is appended and, ideally, before
420
+ * the renderer mounts (hydrating an already-mounted client works — it
421
+ * notifies subscribers — but costs an extra render). Hydrating a client that
422
+ * already holds content throws.
423
+ *
424
+ * **Resuming a live thread.** A snapshot with `done: false` was still
425
+ * streaming. Pass `source` — the markdown behind the snapshot — and the first
426
+ * {@link append} rebuilds parser state in the background: the parser's
427
+ * internals are an `Rc` graph with no serialized form, so continuing a
428
+ * document genuinely requires re-parsing what came before it, but that cost
429
+ * moves OFF the critical path. The hydrated blocks stay on screen and the
430
+ * reader scrolls them while the worker catches up; new chunks queue behind the
431
+ * re-feed and land the moment it does. Without `source` the thread is
432
+ * view-only and appending throws — continuing a document correctly is not
433
+ * possible without the text that precedes it.
434
+ *
435
+ * **Hydration does not verify the blocks against the source.** The snapshot is
436
+ * trusted as produced; `sourceHash` exists so the CALLER can notice its stored
437
+ * source moved on and re-stream instead of painting stale HTML. The resume
438
+ * path re-checks it, lets the fresh parse win, and warns in dev.
439
+ *
440
+ * @throws if the envelope version is unknown, the snapshot is malformed, or
441
+ * this client already holds content. Validation completes before anything is
442
+ * written, so a rejected snapshot leaves the client exactly as it was.
443
+ */
444
+ hydrate(snapshot: PersistableSnapshot, opts?: {
445
+ source?: string;
446
+ }): void;
447
+ /**
448
+ * The first content-bearing op on a hydrated thread: give the parser back the
449
+ * state it could not be handed.
450
+ *
451
+ * There is no way around re-parsing — the core keeps `Rc` graphs with no
452
+ * `Deserialize` — but every part of that cost sits off the critical path. The
453
+ * hydrated blocks are already painted and STAY painted: `softReset` preserves
454
+ * them exactly as the setContent divergence swap does, so the reader keeps
455
+ * reading and scrolling while the worker chews through the history on its own
456
+ * thread. The caller's new chunks need no buffer of ours — `postMessage` is
457
+ * FIFO per worker, so they queue behind the re-feed and are parsed the instant
458
+ * it catches up. When the re-parse's patch lands, `mergeStale` adopts every
459
+ * unchanged block BY REFERENCE (same object, same id), so the swap re-renders
460
+ * and remounts nothing, and the live tail streams on from there.
461
+ */
462
+ private beginResume;
463
+ /** The resume re-feed is no longer wanted: either it has just been issued, or
464
+ * the caller took over by driving the whole document itself. Releases the
465
+ * retained source so one document, not two, stays in memory. */
466
+ private retireHydration;
467
+ /** Drop every trace of a hydrate. An explicit reset() is a brand-new
468
+ * document, so a restored view must not keep intercepting appends or re-feed
469
+ * a source that is no longer the one being streamed. */
470
+ private clearHydration;
332
471
  reset(): void;
333
472
  /**
334
473
  * setContent's divergence reset: rebuild the parser exactly like {@link reset},
package/dist/client.js CHANGED
@@ -4,6 +4,20 @@ import { noteSplice } from "./splice.js";
4
4
  function emptyBlockStore() {
5
5
  return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
6
6
  }
7
+ const HYDRATE_VERSION = 1;
8
+ function sourceFingerprint(source) {
9
+ let h = 2166136261;
10
+ for (let i = 0; i < source.length; i++) {
11
+ h ^= source.charCodeAt(i);
12
+ h = Math.imul(h, 16777619);
13
+ }
14
+ return (h >>> 0).toString(16).padStart(8, "0");
15
+ }
16
+ function isPersistedBlock(b) {
17
+ if (typeof b !== "object" || b === null) return false;
18
+ const x = b;
19
+ return typeof x.id === "number" && Number.isFinite(x.id) && typeof x.html === "string" && typeof x.start === "number" && typeof x.end === "number" && typeof x.open === "boolean" && typeof x.speculative === "boolean" && typeof x.kind === "object" && x.kind !== null && typeof x.kind.type === "string";
20
+ }
7
21
  function htmlToText(html) {
8
22
  return html.replace(/<[^>]*>/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim();
9
23
  }
@@ -344,6 +358,12 @@ class BrookClient {
344
358
  // same-length-or-shorter replacement the growth check misses). Also cleared on
345
359
  // an explicit caller reset().
346
360
  recoveryAttempted = false;
361
+ // Whether any chunk has been appended in this generation. Read only by
362
+ // getPersistable's source fallback, to tell "nothing was ever driven" (whose
363
+ // source is the empty document) from "driven, but this client did not retain
364
+ // it" — the one configuration, recovery off plus manual appends, where the
365
+ // source is genuinely unknown and the caller must supply it.
366
+ appendedAny = false;
347
367
  // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
348
368
  // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
349
369
  // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
@@ -388,6 +408,24 @@ class BrookClient {
388
408
  /** Set by mergeStale when it had to compact a hole out of the view, so
389
409
  * getSnapshot skips caching a view whose indices no longer track `base`. */
390
410
  mergeDropped = false;
411
+ // --- Hydration (see getPersistable / hydrate) ---
412
+ // Set by hydrate(): this store was FILLED FROM JSON, never parsed, so no
413
+ // worker has ever seen this document. Sticky until reset() — a resumed client
414
+ // is still "a hydrated client" as far as getPersistable is concerned.
415
+ hydrated = false;
416
+ // The restored envelope's own fields, kept so the resume can re-check
417
+ // staleness and so getPersistable() can re-emit an untouched snapshot exactly.
418
+ hydratedDone = false;
419
+ hydratedLength = 0;
420
+ hydratedHash = "";
421
+ // The original markdown behind a hydrated snapshot (hydrate's `source`), held
422
+ // ONLY until the resume re-feed consumes it. Null for a `done` snapshot, which
423
+ // is terminal and needs none.
424
+ hydratedSource = null;
425
+ // Latch: the resume re-feed has been issued, or the caller took over by
426
+ // driving the whole document itself. From here append()/finalize() behave
427
+ // exactly as on any other client.
428
+ resumed = false;
391
429
  // Perf
392
430
  appendedBytes = 0;
393
431
  patchCount = 0;
@@ -492,8 +530,15 @@ class BrookClient {
492
530
  this.resetParser();
493
531
  }
494
532
  get ready() {
533
+ if (this.hydrationPending) return true;
495
534
  return this.pw?.ready ?? false;
496
535
  }
536
+ /** True while a hydrated document has never been handed to a parser: the
537
+ * store holds restored blocks and no worker exists. Cleared once the resume
538
+ * re-feed is issued (or the caller re-drives the whole document itself). */
539
+ get hydrationPending() {
540
+ return this.hydrated && !this.resumed;
541
+ }
497
542
  /**
498
543
  * The fatal error that killed this client's worker, or `null` if healthy.
499
544
  *
@@ -508,6 +553,7 @@ class BrookClient {
508
553
  return this.failedError;
509
554
  }
510
555
  whenReady() {
556
+ if (this.hydrationPending) return Promise.resolve();
511
557
  const pw = this.ensureAcquired();
512
558
  return this.pool.whenWorkerReady(pw);
513
559
  }
@@ -520,9 +566,11 @@ class BrookClient {
520
566
  return this.config;
521
567
  }
522
568
  append(chunk) {
569
+ if (this.hydrationPending) this.beginResume();
523
570
  const pw = this.ensureAcquired();
524
571
  this.settleRebind();
525
572
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
573
+ this.appendedAny = true;
526
574
  if (this.recovery) {
527
575
  this.recoveryBuffer += chunk;
528
576
  if (this.recoveryAttempted && this.recoveryBuffer.length > this.recoveredLen) {
@@ -532,6 +580,10 @@ class BrookClient {
532
580
  this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
533
581
  }
534
582
  finalize() {
583
+ if (this.hydrationPending) {
584
+ if (this.hydratedDone) return;
585
+ this.beginResume();
586
+ }
535
587
  const pw = this.ensureAcquired();
536
588
  this.settleRebind();
537
589
  this.finalizePending = true;
@@ -633,12 +685,14 @@ class BrookClient {
633
685
  if (!this.contentDone && content.startsWith(this.lastContent)) {
634
686
  if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
635
687
  this.softReset(this.getSnapshot());
688
+ this.retireHydration();
636
689
  }
637
690
  this.append(content.slice(this.lastContent.length));
638
691
  } else {
639
692
  const displayed = this.getSnapshot();
640
693
  if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
641
694
  else this.reset();
695
+ this.retireHydration();
642
696
  this.append(content);
643
697
  }
644
698
  this.lastContent = content;
@@ -649,6 +703,195 @@ class BrookClient {
649
703
  this.contentDone = true;
650
704
  }
651
705
  }
706
+ /**
707
+ * Capture everything this client currently renders as a plain-JSON
708
+ * {@link PersistableSnapshot}: `JSON.stringify` it, store it beside the
709
+ * thread, and {@link hydrate} it back later to repaint with **no parse at
710
+ * all**. This is the persistence half of instant thread reopen.
711
+ *
712
+ * The committed wire is already a complete serialization — a committed block
713
+ * is emitted exactly once and is final (WIRE.md §2) — so there is nothing to
714
+ * re-derive: the snapshot IS the document. Cost here is one pass to
715
+ * fingerprint the source; cost on the way back in is a `Map` fill.
716
+ *
717
+ * @param source the markdown driven into this stream, used ONLY to compute
718
+ * {@link PersistableSnapshot.sourceHash}. Optional, because a client with
719
+ * `recovery` on (the default) already retains it, as does a
720
+ * `setContent`-driven one; required in the single configuration that holds
721
+ * neither — `recovery: false` plus manual `append()` — where omitting it
722
+ * throws rather than persisting a snapshot no one can check for staleness.
723
+ */
724
+ getPersistable(source) {
725
+ const blocks = this.getSnapshot();
726
+ if (source === void 0 && this.hydrationPending) {
727
+ return {
728
+ hydrateVersion: HYDRATE_VERSION,
729
+ blocks,
730
+ sourceLength: this.hydratedLength,
731
+ sourceHash: this.hydratedHash,
732
+ done: this.hydratedDone
733
+ };
734
+ }
735
+ const src = source ?? this.retainedSource();
736
+ if (src === null) {
737
+ throw new Error(
738
+ "brookmd: getPersistable() needs the source markdown to fingerprint, and this client retains none (constructed with `recovery: false` and driven with append()). Pass it explicitly: client.getPersistable(source)."
739
+ );
740
+ }
741
+ return {
742
+ hydrateVersion: HYDRATE_VERSION,
743
+ blocks,
744
+ sourceLength: src.length,
745
+ sourceHash: sourceFingerprint(src),
746
+ done: this.contentDone
747
+ };
748
+ }
749
+ /** The full driven document when this client happens to hold it: the recovery
750
+ * buffer is exactly that whenever `recovery` is on (the default), and
751
+ * setContent's baseline covers the recovery-off controlled-string mode.
752
+ * `null` means genuinely unknown — reachable only with recovery off AND
753
+ * manual appends. */
754
+ retainedSource() {
755
+ if (this.recovery) return this.recoveryBuffer;
756
+ if (this.lastContent.length > 0) return this.lastContent;
757
+ if (!this.appendedAny) return "";
758
+ return null;
759
+ }
760
+ /**
761
+ * Restore a {@link PersistableSnapshot} into an untouched client. The blocks
762
+ * land in the store as ordinary committed blocks and `getSnapshot()` returns
763
+ * them immediately, so the first paint already has the whole document —
764
+ * **no worker is created, no WASM loads, nothing is parsed**. Reopening a
765
+ * thread costs O(blocks) of JSON handling instead of O(source) of parsing.
766
+ *
767
+ * Call it on a fresh client before anything is appended and, ideally, before
768
+ * the renderer mounts (hydrating an already-mounted client works — it
769
+ * notifies subscribers — but costs an extra render). Hydrating a client that
770
+ * already holds content throws.
771
+ *
772
+ * **Resuming a live thread.** A snapshot with `done: false` was still
773
+ * streaming. Pass `source` — the markdown behind the snapshot — and the first
774
+ * {@link append} rebuilds parser state in the background: the parser's
775
+ * internals are an `Rc` graph with no serialized form, so continuing a
776
+ * document genuinely requires re-parsing what came before it, but that cost
777
+ * moves OFF the critical path. The hydrated blocks stay on screen and the
778
+ * reader scrolls them while the worker catches up; new chunks queue behind the
779
+ * re-feed and land the moment it does. Without `source` the thread is
780
+ * view-only and appending throws — continuing a document correctly is not
781
+ * possible without the text that precedes it.
782
+ *
783
+ * **Hydration does not verify the blocks against the source.** The snapshot is
784
+ * trusted as produced; `sourceHash` exists so the CALLER can notice its stored
785
+ * source moved on and re-stream instead of painting stale HTML. The resume
786
+ * path re-checks it, lets the fresh parse win, and warns in dev.
787
+ *
788
+ * @throws if the envelope version is unknown, the snapshot is malformed, or
789
+ * this client already holds content. Validation completes before anything is
790
+ * written, so a rejected snapshot leaves the client exactly as it was.
791
+ */
792
+ hydrate(snapshot, opts) {
793
+ if (this.hydrated || this.pw !== null || this.getSnapshot().length > 0) {
794
+ throw new Error(
795
+ "brookmd: hydrate() must be called on an untouched client, before any append()/setContent()/finalize(). Construct a new BrookClient (or reset() this one)."
796
+ );
797
+ }
798
+ if (typeof snapshot !== "object" || snapshot === null) {
799
+ throw new Error("brookmd: hydrate() expects a PersistableSnapshot object.");
800
+ }
801
+ if (snapshot.hydrateVersion !== HYDRATE_VERSION) {
802
+ throw new Error(
803
+ `brookmd: cannot hydrate a version ${String(snapshot.hydrateVersion)} snapshot \u2014 this build reads version ${HYDRATE_VERSION}. Discard it and re-stream the source.`
804
+ );
805
+ }
806
+ if (!Array.isArray(snapshot.blocks) || typeof snapshot.done !== "boolean" || typeof snapshot.sourceHash !== "string" || typeof snapshot.sourceLength !== "number" || !Number.isFinite(snapshot.sourceLength) || snapshot.sourceLength < 0) {
807
+ throw new Error(
808
+ "brookmd: malformed PersistableSnapshot (bad blocks / done / sourceHash / sourceLength)."
809
+ );
810
+ }
811
+ const committed = /* @__PURE__ */ new Map();
812
+ const committedOrder = new Array(snapshot.blocks.length);
813
+ for (let i = 0; i < snapshot.blocks.length; i++) {
814
+ const b = snapshot.blocks[i];
815
+ if (!isPersistedBlock(b)) {
816
+ throw new Error(
817
+ `brookmd: malformed PersistableSnapshot \u2014 block at index ${i} is not a Block.`
818
+ );
819
+ }
820
+ if (committed.has(b.id)) {
821
+ throw new Error(`brookmd: malformed PersistableSnapshot \u2014 duplicate block id ${b.id}.`);
822
+ }
823
+ committedOrder[i] = b.id;
824
+ committed.set(b.id, b);
825
+ }
826
+ this.store = { committed, committedOrder, active: [], snapshot: snapshot.blocks.slice() };
827
+ this.hydrated = true;
828
+ this.hydratedDone = snapshot.done;
829
+ this.hydratedLength = snapshot.sourceLength;
830
+ this.hydratedHash = snapshot.sourceHash;
831
+ this.hydratedSource = snapshot.done ? null : opts?.source ?? null;
832
+ if (this.hydratedSource !== null) this.lastContent = this.hydratedSource;
833
+ this.contentDone = snapshot.done;
834
+ this.emit(true);
835
+ }
836
+ /**
837
+ * The first content-bearing op on a hydrated thread: give the parser back the
838
+ * state it could not be handed.
839
+ *
840
+ * There is no way around re-parsing — the core keeps `Rc` graphs with no
841
+ * `Deserialize` — but every part of that cost sits off the critical path. The
842
+ * hydrated blocks are already painted and STAY painted: `softReset` preserves
843
+ * them exactly as the setContent divergence swap does, so the reader keeps
844
+ * reading and scrolling while the worker chews through the history on its own
845
+ * thread. The caller's new chunks need no buffer of ours — `postMessage` is
846
+ * FIFO per worker, so they queue behind the re-feed and are parsed the instant
847
+ * it catches up. When the re-parse's patch lands, `mergeStale` adopts every
848
+ * unchanged block BY REFERENCE (same object, same id), so the swap re-renders
849
+ * and remounts nothing, and the live tail streams on from there.
850
+ */
851
+ beginResume() {
852
+ if (this.hydratedLength === 0 && this.store.snapshot.length === 0) {
853
+ this.retireHydration();
854
+ return;
855
+ }
856
+ if (this.hydratedDone) {
857
+ throw new Error(
858
+ "brookmd: this client was hydrated from a FINALIZED snapshot (done: true), and a completed thread cannot be appended to. Call reset() to start a new stream, or hydrate a `done: false` snapshot with its `source` to resume one."
859
+ );
860
+ }
861
+ const source = this.hydratedSource;
862
+ if (source === null) {
863
+ throw new Error(
864
+ "brookmd: cannot append to a hydrated client without its source. Continuing a document means re-parsing the text that came before it \u2014 pass it at hydrate time: client.hydrate(snapshot, { source })."
865
+ );
866
+ }
867
+ this.retireHydration();
868
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && (source.length !== this.hydratedLength || sourceFingerprint(source) !== this.hydratedHash)) {
869
+ warnOnce(
870
+ "hydrate-stale",
871
+ "brookmd: the source passed to hydrate() does not match the snapshot's fingerprint, so the resume re-parse will replace the hydrated blocks. Compare `snapshot.sourceHash` with `sourceFingerprint(source)` before hydrating and re-stream when they differ."
872
+ );
873
+ }
874
+ this.softReset(this.getSnapshot());
875
+ this.append(source);
876
+ }
877
+ /** The resume re-feed is no longer wanted: either it has just been issued, or
878
+ * the caller took over by driving the whole document itself. Releases the
879
+ * retained source so one document, not two, stays in memory. */
880
+ retireHydration() {
881
+ this.resumed = true;
882
+ this.hydratedSource = null;
883
+ }
884
+ /** Drop every trace of a hydrate. An explicit reset() is a brand-new
885
+ * document, so a restored view must not keep intercepting appends or re-feed
886
+ * a source that is no longer the one being streamed. */
887
+ clearHydration() {
888
+ this.hydrated = false;
889
+ this.resumed = false;
890
+ this.hydratedDone = false;
891
+ this.hydratedSource = null;
892
+ this.hydratedLength = 0;
893
+ this.hydratedHash = "";
894
+ }
652
895
  reset() {
653
896
  const hadContent = this.getSnapshot().length > 0;
654
897
  this.staleSnapshot = null;
@@ -657,6 +900,7 @@ class BrookClient {
657
900
  this.failedError = null;
658
901
  this.recoveryAttempted = false;
659
902
  this.pendingRebind = false;
903
+ this.clearHydration();
660
904
  this.resetParser();
661
905
  if (hadContent) this.emit(true);
662
906
  }
@@ -712,6 +956,7 @@ class BrookClient {
712
956
  this.firstAppendMs = 0;
713
957
  this.retainedBytes = 0;
714
958
  this.wasmMemoryBytes = 0;
959
+ this.appendedAny = false;
715
960
  this.lastContent = "";
716
961
  this.contentDone = false;
717
962
  this.recoveryBuffer = "";
@@ -1035,5 +1280,6 @@ export {
1035
1280
  __resetDefaultPool,
1036
1281
  applyPatch,
1037
1282
  emptyBlockStore,
1038
- getDefaultPool
1283
+ getDefaultPool,
1284
+ sourceFingerprint
1039
1285
  };
package/dist/element.js CHANGED
@@ -20,7 +20,8 @@ const CONFIG_ATTRS = [
20
20
  "soft-breaks",
21
21
  "a11y",
22
22
  "unsafe-html",
23
- "block-html"
23
+ "block-html",
24
+ "retain-committed-html"
24
25
  ];
25
26
  function defineBrookMarkdown(tag = "brook-markdown") {
26
27
  if (typeof customElements === "undefined") return;
@@ -162,6 +163,7 @@ function defineBrookMarkdown(tag = "brook-markdown") {
162
163
  set("a11y", "a11y");
163
164
  set("unsafe-html", "unsafeHtml");
164
165
  set("block-html", "blockHtml");
166
+ set("retain-committed-html", "retainCommittedHtml");
165
167
  const tags = this.getAttribute("component-tags");
166
168
  if (tags !== null) {
167
169
  const list = tags.split(/[\s,]+/).filter(Boolean);
package/dist/index.d.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  * // ... wherever your tokens land: client.append(deltaText);
16
16
  * client.finalize();
17
17
  */
18
- export { BrookClient, BrookPool, getDefaultPool } from "./client.js";
18
+ export { BrookClient, BrookPool, getDefaultPool, sourceFingerprint } from "./client.js";
19
+ export type { PersistableSnapshot } from "./client.js";
19
20
  export { BrookMarkdown, useBrookStream, useBrookMarkdownString } from "./react.js";
20
21
  export { highlight, supportedLangs } from "./hi.js";
21
22
  export { htmlToReact, parseTrustedHtml, safeUrl, wrapLink } from "./html-to-react.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BrookClient, BrookPool, getDefaultPool } from "./client.js";
1
+ import { BrookClient, BrookPool, getDefaultPool, sourceFingerprint } from "./client.js";
2
2
  import { BrookMarkdown, useBrookStream, useBrookMarkdownString } from "./react.js";
3
3
  import { highlight, supportedLangs } from "./hi.js";
4
4
  import { htmlToReact, parseTrustedHtml, safeUrl, wrapLink } from "./html-to-react.js";
@@ -11,6 +11,7 @@ export {
11
11
  htmlToReact,
12
12
  parseTrustedHtml,
13
13
  safeUrl,
14
+ sourceFingerprint,
14
15
  supportedLangs,
15
16
  useBrookMarkdownString,
16
17
  useBrookStream,
package/dist/server.js CHANGED
@@ -54,6 +54,7 @@ function makeParser(config) {
54
54
  p.setBlockHtml(config?.blockHtml ?? false);
55
55
  p.setAllowSchemes(config?.allowSchemes ?? []);
56
56
  p.setBlockData(config?.blockData ?? false);
57
+ p.setRetainCommittedHtml(true);
57
58
  return p;
58
59
  }
59
60
  function requireReady() {
@@ -513,6 +513,22 @@ export interface ParserConfig {
513
513
  * serde bytes; output and the `kind` serde shape stay byte-identical when off).
514
514
  */
515
515
  blockData?: boolean;
516
+ /**
517
+ * Keep every committed block's rendered HTML retained **inside the parser**.
518
+ *
519
+ * Defaults to **false on the streaming path** (the worker), which is what you
520
+ * want: the client receives each committed block exactly once, in the patch
521
+ * that commits it, and stores it itself — so a second copy sitting in WASM for
522
+ * the life of the stream serves nobody. Dropping it roughly halves a long
523
+ * stream's retained bytes (`onPatch`'s `retainedBytes`); the wire is
524
+ * byte-identical either way.
525
+ *
526
+ * Set `true` only if you need the parser itself to still hold the whole
527
+ * rendered document. The server one-shot renderers (`renderToString`,
528
+ * `parseToBlocks`) read the document back out of the parser and therefore pin
529
+ * this on regardless of what you pass.
530
+ */
531
+ retainCommittedHtml?: boolean;
516
532
  }
517
533
  export type ToWorker = {
518
534
  type: "append";
@@ -139,6 +139,18 @@ export class BrookParser {
139
139
  * markers all stay strictly conformant.
140
140
  */
141
141
  setLenientLists(on: boolean): void;
142
+ /**
143
+ * Keep every committed block's rendered HTML retained inside the parser for
144
+ * `allBlocks()`. ON by default. Turn it OFF in a pure STREAMING consumer —
145
+ * one that reads each committed block exactly once out of its patch and
146
+ * never calls `allBlocks()` (the npm worker): the HTML is then released the
147
+ * moment the block is emitted, so a long stream retains the source buffer
148
+ * plus the open tail instead of the whole rendered document (`retainedBytes`
149
+ * reflects it). Patch bytes are identical either way; the only cost is that
150
+ * `allBlocks()` then reports committed blocks with an EMPTY `html` (id,
151
+ * kind, start, end, open, speculative all stay exact).
152
+ */
153
+ setRetainCommittedHtml(on: boolean): void;
142
154
  /**
143
155
  * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
144
156
  * `<br>` — the `remark-breaks` convention, where one Enter is one visual
@@ -188,6 +200,7 @@ export interface InitOutput {
188
200
  readonly brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
189
201
  readonly brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
190
202
  readonly brookparser_setLenientLists: (a: number, b: number) => void;
203
+ readonly brookparser_setRetainCommittedHtml: (a: number, b: number) => void;
191
204
  readonly brookparser_setSoftBreaks: (a: number, b: number) => void;
192
205
  readonly brookparser_setUnsafeHtml: (a: number, b: number) => void;
193
206
  readonly brookparser_setWireDelta: (a: number, b: number) => void;
@@ -290,6 +290,21 @@ export class BrookParser {
290
290
  setLenientLists(on) {
291
291
  wasm.brookparser_setLenientLists(this.__wbg_ptr, on);
292
292
  }
293
+ /**
294
+ * Keep every committed block's rendered HTML retained inside the parser for
295
+ * `allBlocks()`. ON by default. Turn it OFF in a pure STREAMING consumer —
296
+ * one that reads each committed block exactly once out of its patch and
297
+ * never calls `allBlocks()` (the npm worker): the HTML is then released the
298
+ * moment the block is emitted, so a long stream retains the source buffer
299
+ * plus the open tail instead of the whole rendered document (`retainedBytes`
300
+ * reflects it). Patch bytes are identical either way; the only cost is that
301
+ * `allBlocks()` then reports committed blocks with an EMPTY `html` (id,
302
+ * kind, start, end, open, speculative all stay exact).
303
+ * @param {boolean} on
304
+ */
305
+ setRetainCommittedHtml(on) {
306
+ wasm.brookparser_setRetainCommittedHtml(this.__wbg_ptr, on);
307
+ }
293
308
  /**
294
309
  * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
295
310
  * `<br>` — the `remark-breaks` convention, where one Enter is one visual
Binary file
@@ -22,6 +22,7 @@ export const brookparser_setGfmTagfilter: (a: number, b: number) => void;
22
22
  export const brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
23
23
  export const brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
24
24
  export const brookparser_setLenientLists: (a: number, b: number) => void;
25
+ export const brookparser_setRetainCommittedHtml: (a: number, b: number) => void;
25
26
  export const brookparser_setSoftBreaks: (a: number, b: number) => void;
26
27
  export const brookparser_setUnsafeHtml: (a: number, b: number) => void;
27
28
  export const brookparser_setWireDelta: (a: number, b: number) => void;
package/dist/worker.js CHANGED
@@ -29,6 +29,7 @@ const core = new WorkerCore({
29
29
  p.setBlockHtml(c?.blockHtml ?? false);
30
30
  p.setAllowSchemes(c?.allowSchemes ?? []);
31
31
  p.setBlockData(c?.blockData ?? false);
32
+ p.setRetainCommittedHtml(c?.retainCommittedHtml ?? false);
32
33
  p.setWireDelta(true);
33
34
  return p;
34
35
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./dist/worker.js", "./dist/styles.css"],