brookmd 0.27.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/dist/client.js CHANGED
@@ -1,8 +1,23 @@
1
1
  import { warnOnce } from "./warn.js";
2
2
  import { createWorker } from "./asset-urls.js";
3
+ import { noteSplice } from "./splice.js";
3
4
  function emptyBlockStore() {
4
5
  return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
5
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
+ }
6
21
  function htmlToText(html) {
7
22
  return html.replace(/<[^>]*>/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim();
8
23
  }
@@ -18,7 +33,9 @@ function applyPatch(store, patch) {
18
33
  const { html_delta, ...rest } = entry;
19
34
  const prev = store.active.find((b) => b.id === entry.id);
20
35
  if (!prev) throw new Error(`brookmd: html_delta for block ${entry.id} without a base`);
21
- active[i] = { ...rest, html: prev.html.slice(0, html_delta.keep_units) + html_delta.append };
36
+ const next2 = { ...rest, html: prev.html.slice(0, html_delta.keep_units) + html_delta.append };
37
+ active[i] = next2;
38
+ noteSplice(next2, prev, html_delta.keep_units);
22
39
  } else {
23
40
  active[i] = entry;
24
41
  }
@@ -341,6 +358,12 @@ class BrookClient {
341
358
  // same-length-or-shorter replacement the growth check misses). Also cleared on
342
359
  // an explicit caller reset().
343
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;
344
367
  // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
345
368
  // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
346
369
  // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
@@ -385,6 +408,24 @@ class BrookClient {
385
408
  /** Set by mergeStale when it had to compact a hole out of the view, so
386
409
  * getSnapshot skips caching a view whose indices no longer track `base`. */
387
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;
388
429
  // Perf
389
430
  appendedBytes = 0;
390
431
  patchCount = 0;
@@ -489,8 +530,15 @@ class BrookClient {
489
530
  this.resetParser();
490
531
  }
491
532
  get ready() {
533
+ if (this.hydrationPending) return true;
492
534
  return this.pw?.ready ?? false;
493
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
+ }
494
542
  /**
495
543
  * The fatal error that killed this client's worker, or `null` if healthy.
496
544
  *
@@ -505,6 +553,7 @@ class BrookClient {
505
553
  return this.failedError;
506
554
  }
507
555
  whenReady() {
556
+ if (this.hydrationPending) return Promise.resolve();
508
557
  const pw = this.ensureAcquired();
509
558
  return this.pool.whenWorkerReady(pw);
510
559
  }
@@ -517,9 +566,11 @@ class BrookClient {
517
566
  return this.config;
518
567
  }
519
568
  append(chunk) {
569
+ if (this.hydrationPending) this.beginResume();
520
570
  const pw = this.ensureAcquired();
521
571
  this.settleRebind();
522
572
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
573
+ this.appendedAny = true;
523
574
  if (this.recovery) {
524
575
  this.recoveryBuffer += chunk;
525
576
  if (this.recoveryAttempted && this.recoveryBuffer.length > this.recoveredLen) {
@@ -529,6 +580,10 @@ class BrookClient {
529
580
  this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
530
581
  }
531
582
  finalize() {
583
+ if (this.hydrationPending) {
584
+ if (this.hydratedDone) return;
585
+ this.beginResume();
586
+ }
532
587
  const pw = this.ensureAcquired();
533
588
  this.settleRebind();
534
589
  this.finalizePending = true;
@@ -630,12 +685,14 @@ class BrookClient {
630
685
  if (!this.contentDone && content.startsWith(this.lastContent)) {
631
686
  if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
632
687
  this.softReset(this.getSnapshot());
688
+ this.retireHydration();
633
689
  }
634
690
  this.append(content.slice(this.lastContent.length));
635
691
  } else {
636
692
  const displayed = this.getSnapshot();
637
693
  if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
638
694
  else this.reset();
695
+ this.retireHydration();
639
696
  this.append(content);
640
697
  }
641
698
  this.lastContent = content;
@@ -646,6 +703,195 @@ class BrookClient {
646
703
  this.contentDone = true;
647
704
  }
648
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
+ }
649
895
  reset() {
650
896
  const hadContent = this.getSnapshot().length > 0;
651
897
  this.staleSnapshot = null;
@@ -654,6 +900,7 @@ class BrookClient {
654
900
  this.failedError = null;
655
901
  this.recoveryAttempted = false;
656
902
  this.pendingRebind = false;
903
+ this.clearHydration();
657
904
  this.resetParser();
658
905
  if (hadContent) this.emit(true);
659
906
  }
@@ -709,6 +956,7 @@ class BrookClient {
709
956
  this.firstAppendMs = 0;
710
957
  this.retainedBytes = 0;
711
958
  this.wasmMemoryBytes = 0;
959
+ this.appendedAny = false;
712
960
  this.lastContent = "";
713
961
  this.contentDone = false;
714
962
  this.recoveryBuffer = "";
@@ -1032,5 +1280,6 @@ export {
1032
1280
  __resetDefaultPool,
1033
1281
  applyPatch,
1034
1282
  emptyBlockStore,
1035
- getDefaultPool
1283
+ getDefaultPool,
1284
+ sourceFingerprint
1036
1285
  };
package/dist/dom.d.ts CHANGED
@@ -129,7 +129,24 @@ export interface MountOptions {
129
129
  * `client.getMetrics().rebuildCount`.
130
130
  */
131
131
  onRenderMetrics?: RenderMetricsHook;
132
+ /**
133
+ * @internal TEST-ONLY. Turn off the incremental apply fast paths — the open
134
+ * code block's frozen/tail mirror and the delta-driven child splice — so every
135
+ * changed open block rebuilds its whole node, exactly as it did before those
136
+ * existed. The DOM-parity fuzz mounts one renderer with this on and one with it
137
+ * off over the same patch stream and asserts their `innerHTML` matches after
138
+ * every sync: correctness must never depend on a fast path firing. Not part of
139
+ * the supported API and never useful in an app.
140
+ */
141
+ __fullRebuild?: boolean;
132
142
  }
143
+ /** @internal Test-only. */
144
+ export declare function __keyedStats(): {
145
+ attempts: number;
146
+ hits: number;
147
+ };
148
+ /** @internal Test-only. */
149
+ export declare function __resetKeyedStats(): void;
133
150
  export declare function mountBrookMarkdown(client: BrookClient, container: HTMLElement, options?: MountOptions): MountHandle;
134
151
  /**
135
152
  * Derive the streaming tail's block id from an ordered snapshot: the id of the