brookmd 0.22.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 +1229 -0
- package/LICENSE +21 -0
- package/README.md +1265 -0
- package/dist/block-props.d.ts +18 -0
- package/dist/block-props.js +75 -0
- package/dist/client.d.ts +370 -0
- package/dist/client.js +754 -0
- package/dist/decorate.d.ts +24 -0
- package/dist/decorate.js +71 -0
- package/dist/dom.d.ts +130 -0
- package/dist/dom.js +627 -0
- package/dist/element.d.ts +20 -0
- package/dist/element.js +288 -0
- package/dist/hi.d.ts +12 -0
- package/dist/hi.js +215 -0
- package/dist/html-to-react.d.ts +61 -0
- package/dist/html-to-react.js +338 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +18 -0
- package/dist/morph.d.ts +28 -0
- package/dist/morph.js +166 -0
- package/dist/react.d.ts +236 -0
- package/dist/react.js +539 -0
- package/dist/renderers/CodeBlock.d.ts +7 -0
- package/dist/renderers/CodeBlock.js +75 -0
- package/dist/renderers/Math.d.ts +14 -0
- package/dist/renderers/Math.js +15 -0
- package/dist/renderers/Mermaid.d.ts +13 -0
- package/dist/renderers/Mermaid.js +15 -0
- package/dist/server-react.d.ts +32 -0
- package/dist/server-react.js +48 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +82 -0
- package/dist/solid.d.ts +104 -0
- package/dist/solid.js +54 -0
- package/dist/styles.css +188 -0
- package/dist/svelte.d.ts +80 -0
- package/dist/svelte.js +59 -0
- package/dist/types-core.d.ts +436 -0
- package/dist/types-core.js +0 -0
- package/dist/types-react.d.ts +13 -0
- package/dist/types-react.js +0 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/dist/url-safety.d.ts +12 -0
- package/dist/url-safety.js +45 -0
- package/dist/vue.d.ts +94 -0
- package/dist/vue.js +79 -0
- package/dist/wasm/LICENSE +21 -0
- package/dist/wasm/README.md +71 -0
- package/dist/wasm/brook_md_core.d.ts +166 -0
- package/dist/wasm/brook_md_core.js +512 -0
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/dist/wasm/brook_md_core_bg.wasm.d.ts +26 -0
- package/dist/worker-core.d.ts +65 -0
- package/dist/worker-core.js +155 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +49 -0
- package/package.json +87 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
function emptyBlockStore() {
|
|
2
|
+
return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
3
|
+
}
|
|
4
|
+
function htmlToText(html) {
|
|
5
|
+
return html.replace(/<[^>]*>/g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&").replace(/\s+/g, " ").trim();
|
|
6
|
+
}
|
|
7
|
+
function applyPatch(store, patch) {
|
|
8
|
+
for (const b of patch.newly_committed) {
|
|
9
|
+
if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
|
|
10
|
+
store.committed.set(b.id, b);
|
|
11
|
+
}
|
|
12
|
+
store.active = patch.active;
|
|
13
|
+
const next = new Array(store.committedOrder.length + store.active.length);
|
|
14
|
+
for (let i = 0; i < store.committedOrder.length; i++) {
|
|
15
|
+
next[i] = store.committed.get(store.committedOrder[i]);
|
|
16
|
+
}
|
|
17
|
+
for (let i = 0; i < store.active.length; i++) {
|
|
18
|
+
next[store.committedOrder.length + i] = store.active[i];
|
|
19
|
+
}
|
|
20
|
+
store.snapshot = next;
|
|
21
|
+
}
|
|
22
|
+
class BrookPool {
|
|
23
|
+
constructor(factory, cap) {
|
|
24
|
+
this.factory = factory;
|
|
25
|
+
this.cap = cap;
|
|
26
|
+
}
|
|
27
|
+
factory;
|
|
28
|
+
cap;
|
|
29
|
+
workers = [];
|
|
30
|
+
handlers = /* @__PURE__ */ new Map();
|
|
31
|
+
nextStreamId = 1;
|
|
32
|
+
/** Reserve a stream id and assign a worker, registering its message handler. */
|
|
33
|
+
acquire(handler) {
|
|
34
|
+
const streamId = this.nextStreamId++;
|
|
35
|
+
const pw = this.pick();
|
|
36
|
+
pw.streamCount++;
|
|
37
|
+
pw.streamIds.add(streamId);
|
|
38
|
+
this.handlers.set(streamId, handler);
|
|
39
|
+
return { streamId, pw };
|
|
40
|
+
}
|
|
41
|
+
/** Free a stream's parser in its worker; keep the worker warm for siblings. */
|
|
42
|
+
release(streamId, pw) {
|
|
43
|
+
this.handlers.delete(streamId);
|
|
44
|
+
pw.streamIds.delete(streamId);
|
|
45
|
+
pw.streamCount = Math.max(0, pw.streamCount - 1);
|
|
46
|
+
try {
|
|
47
|
+
pw.worker.postMessage({ type: "dispose", streamId });
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Inverse of {@link release}: re-register a stream's handler so it receives
|
|
52
|
+
* patches again. For React StrictMode's dev double-mount, which destroys a
|
|
53
|
+
* client on the simulated unmount and remounts the SAME instance. The worker
|
|
54
|
+
* lazily recreates the disposed parser on the next append. */
|
|
55
|
+
reattach(streamId, pw, handler) {
|
|
56
|
+
if (!this.handlers.has(streamId)) {
|
|
57
|
+
pw.streamCount++;
|
|
58
|
+
pw.streamIds.add(streamId);
|
|
59
|
+
}
|
|
60
|
+
this.handlers.set(streamId, handler);
|
|
61
|
+
}
|
|
62
|
+
send(pw, msg) {
|
|
63
|
+
pw.worker.postMessage(msg);
|
|
64
|
+
}
|
|
65
|
+
/** Resolves when the given worker has finished WASM init; rejects if it failed. */
|
|
66
|
+
whenWorkerReady(pw) {
|
|
67
|
+
if (pw.ready) return Promise.resolve();
|
|
68
|
+
if (pw.failed) return Promise.reject(pw.failed);
|
|
69
|
+
return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Eagerly spin up one worker so WASM init starts BEFORE the first stream —
|
|
73
|
+
* taking the one-time init off the first-token critical path (e.g. call
|
|
74
|
+
* `getDefaultPool().warm()` on app load / route entry). Reuses a live worker
|
|
75
|
+
* if one exists; the warm worker is the one the first stream attaches to (it
|
|
76
|
+
* has spare capacity), so the work is not wasted. Resolves when that worker has
|
|
77
|
+
* finished initializing WASM; rejects if init fails fatally. Browser-only (it
|
|
78
|
+
* constructs a `Worker`).
|
|
79
|
+
*/
|
|
80
|
+
warm() {
|
|
81
|
+
const live = this.workers.filter((w) => !w.failed);
|
|
82
|
+
const pw = live[0] ?? this.create();
|
|
83
|
+
return this.whenWorkerReady(pw);
|
|
84
|
+
}
|
|
85
|
+
/** Terminate every worker (test teardown / full shutdown). */
|
|
86
|
+
disposeAll() {
|
|
87
|
+
for (const pw of this.workers) {
|
|
88
|
+
try {
|
|
89
|
+
pw.worker.terminate();
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.workers = [];
|
|
94
|
+
this.handlers.clear();
|
|
95
|
+
}
|
|
96
|
+
get workerCount() {
|
|
97
|
+
return this.workers.length;
|
|
98
|
+
}
|
|
99
|
+
// Create a new worker while under cap and every live worker is busy; otherwise
|
|
100
|
+
// attach to the least-loaded LIVE worker. A fatally-failed worker is never
|
|
101
|
+
// handed out (a stream on it would post into a dead worker and hang) — it is
|
|
102
|
+
// retained only to reject outstanding whenWorkerReady waiters.
|
|
103
|
+
pick() {
|
|
104
|
+
const live = this.workers.filter((w) => !w.failed);
|
|
105
|
+
if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
|
|
106
|
+
return this.create();
|
|
107
|
+
}
|
|
108
|
+
if (live.length === 0) return this.create();
|
|
109
|
+
return live.reduce((a, b) => b.streamCount < a.streamCount ? b : a);
|
|
110
|
+
}
|
|
111
|
+
create() {
|
|
112
|
+
const pw = {
|
|
113
|
+
worker: this.factory(),
|
|
114
|
+
ready: false,
|
|
115
|
+
failed: null,
|
|
116
|
+
streamCount: 0,
|
|
117
|
+
streamIds: /* @__PURE__ */ new Set(),
|
|
118
|
+
readyWaiters: []
|
|
119
|
+
};
|
|
120
|
+
pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
|
|
121
|
+
this.workers.push(pw);
|
|
122
|
+
return pw;
|
|
123
|
+
}
|
|
124
|
+
onMessage(pw, msg) {
|
|
125
|
+
if (msg.type === "ready") {
|
|
126
|
+
pw.ready = true;
|
|
127
|
+
const waiters = pw.readyWaiters;
|
|
128
|
+
pw.readyWaiters = [];
|
|
129
|
+
for (const w of waiters) w.resolve();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (msg.type === "error" && msg.fatal) {
|
|
133
|
+
const err = new Error(msg.message);
|
|
134
|
+
pw.failed = err;
|
|
135
|
+
const waiters = pw.readyWaiters;
|
|
136
|
+
pw.readyWaiters = [];
|
|
137
|
+
for (const w of waiters) {
|
|
138
|
+
try {
|
|
139
|
+
w.reject(err);
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const sid of pw.streamIds) this.dispatch(sid, msg);
|
|
144
|
+
try {
|
|
145
|
+
pw.worker.terminate();
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
const idx = this.workers.indexOf(pw);
|
|
149
|
+
if (idx !== -1) this.workers.splice(idx, 1);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.dispatch(msg.streamId, msg);
|
|
153
|
+
}
|
|
154
|
+
// Route a message to a stream's handler, isolating a throwing client callback
|
|
155
|
+
// (e.g. a user-supplied onError) so it can neither break the worker message
|
|
156
|
+
// loop nor starve sibling streams sharing this worker.
|
|
157
|
+
dispatch(streamId, msg) {
|
|
158
|
+
try {
|
|
159
|
+
this.handlers.get(streamId)?.(msg);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error("brookmd: stream message handler threw", e);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const ID_NAMESPACE_STRIDE = 4294967296;
|
|
166
|
+
function poolCap() {
|
|
167
|
+
const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
|
|
168
|
+
return Math.min(hc || 4, 8);
|
|
169
|
+
}
|
|
170
|
+
let defaultPool = null;
|
|
171
|
+
function getDefaultPool() {
|
|
172
|
+
if (!defaultPool) {
|
|
173
|
+
defaultPool = new BrookPool(
|
|
174
|
+
() => new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
|
|
175
|
+
poolCap()
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return defaultPool;
|
|
179
|
+
}
|
|
180
|
+
function __resetDefaultPool() {
|
|
181
|
+
defaultPool = null;
|
|
182
|
+
}
|
|
183
|
+
class BrookClient {
|
|
184
|
+
pool;
|
|
185
|
+
pw = null;
|
|
186
|
+
streamId = 0;
|
|
187
|
+
config;
|
|
188
|
+
configSent = false;
|
|
189
|
+
listeners = /* @__PURE__ */ new Set();
|
|
190
|
+
store = emptyBlockStore();
|
|
191
|
+
onError;
|
|
192
|
+
onBlock;
|
|
193
|
+
attached = true;
|
|
194
|
+
// Diff baseline for setContent(): the full string fed in so far, and whether
|
|
195
|
+
// it has been finalized. Cleared by reset()/reattach() (the worker drops the
|
|
196
|
+
// parser there, so the baseline is stale and the document must be re-fed).
|
|
197
|
+
lastContent = "";
|
|
198
|
+
contentDone = false;
|
|
199
|
+
// Opt-in rAF coalescing (see constructor `coalesce`). When on AND
|
|
200
|
+
// requestAnimationFrame exists, intra-frame emit()s collapse into ONE
|
|
201
|
+
// rAF-scheduled flush to listeners — the React useSyncExternalStore path then
|
|
202
|
+
// renders once per frame instead of once per patch (the DOM renderer already
|
|
203
|
+
// batches to 1/frame independently). Lossless: committed blocks are
|
|
204
|
+
// reference-stable, so a dropped intermediate notify only skips a tail-only
|
|
205
|
+
// render that the next flush supersedes. The finalize/done patch is exempt and
|
|
206
|
+
// flushes synchronously, never deferred to the next frame.
|
|
207
|
+
coalesce = false;
|
|
208
|
+
rafHandle = null;
|
|
209
|
+
// Set by finalize(); the next patch's emit flushes synchronously (a 'done'
|
|
210
|
+
// notification must not be deferred a frame) and clears it. Belt-and-suspenders
|
|
211
|
+
// alongside the per-patch `final` flag, which is the authoritative signal.
|
|
212
|
+
finalizePending = false;
|
|
213
|
+
// Stream generation, bumped on reset(). Stamped on every worker message and
|
|
214
|
+
// echoed back on each patch; a patch whose epoch is older than this is a
|
|
215
|
+
// pre-reset straggler and is dropped before it can repopulate the cleared store.
|
|
216
|
+
epoch = 0;
|
|
217
|
+
// --- Preserved-view divergence swap (setContent's reset+reparse path) ---
|
|
218
|
+
// The displayed snapshot captured by softReset(). While set, getSnapshot()
|
|
219
|
+
// returns a positional merge of this view over the rebuilding store, so the
|
|
220
|
+
// document never blanks out during a divergence reparse and unchanged blocks
|
|
221
|
+
// keep their object identity AND id (React key / DOM node key) across the
|
|
222
|
+
// swap. It persists after the reparse completes — dropping it would revert
|
|
223
|
+
// the adopted ids and remount every block one notify later.
|
|
224
|
+
staleSnapshot = null;
|
|
225
|
+
// Set when the reparse's terminal (final) patch lands: the merge stops
|
|
226
|
+
// padding with the old document's tail, so a shorter replacement trims to the
|
|
227
|
+
// new length on that very notify.
|
|
228
|
+
staleTrimmed = false;
|
|
229
|
+
// Id offset applied to non-adopted new blocks in a merged view. A divergence
|
|
230
|
+
// reparse restarts core block ids at 0 (fresh parser), and streamed ids are
|
|
231
|
+
// chunk-dependent (tail reparses burn ids) — so a changed block's new id can
|
|
232
|
+
// collide with a retained old block's id in the same merged snapshot.
|
|
233
|
+
// Bumped by ID_NAMESPACE_STRIDE per generation, which keeps every merged id
|
|
234
|
+
// provably unique: adopted ids are all below the current namespace.
|
|
235
|
+
idNamespace = 0;
|
|
236
|
+
// getSnapshot() cache: (store.snapshot ref, trimmed) → merged array. Repeated
|
|
237
|
+
// reads between notifies must return the SAME reference — the
|
|
238
|
+
// useSyncExternalStore cached-snapshot contract.
|
|
239
|
+
mergeCache = null;
|
|
240
|
+
// Perf
|
|
241
|
+
appendedBytes = 0;
|
|
242
|
+
patchCount = 0;
|
|
243
|
+
totalParseMicros = 0;
|
|
244
|
+
lastPatchMs = 0;
|
|
245
|
+
firstAppendMs = 0;
|
|
246
|
+
retainedBytes = 0;
|
|
247
|
+
wasmMemoryBytes = 0;
|
|
248
|
+
// Render-path observability (advanced ONLY when an onRenderMetrics hook is
|
|
249
|
+
// wired into a renderer; zero-cost otherwise). renderCount = React BlockView
|
|
250
|
+
// body renders; rebuildCount = DOM node rebuilds.
|
|
251
|
+
renderCount = 0;
|
|
252
|
+
rebuildCount = 0;
|
|
253
|
+
/**
|
|
254
|
+
* @param options.pool worker pool to join (defaults to the shared
|
|
255
|
+
* process-wide pool — pass a dedicated `BrookPool` only for isolation).
|
|
256
|
+
* @param options.config per-stream parser flags (see {@link ParserConfig});
|
|
257
|
+
* omitted fields use library defaults. Applied once, immutable thereafter.
|
|
258
|
+
* @param options.onError invoked on a worker/parse error or a fatal WASM-init
|
|
259
|
+
* failure (`fatal: true`). Without it, errors are only `console.error`d and
|
|
260
|
+
* a load failure surfaces solely as a rejected {@link BrookClient.whenReady}.
|
|
261
|
+
* @param options.onBlock invoked once per block as it commits (in document
|
|
262
|
+
* order, after the store updates) — for side effects like lazily
|
|
263
|
+
* highlighting a finished code block or analytics. A committed block never
|
|
264
|
+
* re-fires; the streaming tail does not (subscribe for live tail updates).
|
|
265
|
+
* NOTE: this is a PARSER-commit hook — the block carries the parser's raw
|
|
266
|
+
* id. During a setContent divergence swap the rendered view may show that
|
|
267
|
+
* block under a different id (an adopted old id, or a namespaced one), so
|
|
268
|
+
* correlate with rendered blocks via subscribe()+getSnapshot(), not this id.
|
|
269
|
+
* @param options.coalesce opt-in (default `false`): collapse multiple
|
|
270
|
+
* intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
|
|
271
|
+
* flush to subscribers, so a React `useSyncExternalStore` consumer renders at
|
|
272
|
+
* most once per frame instead of once per patch. Lossless — committed blocks
|
|
273
|
+
* are reference-stable, so only superseded tail-only renders are skipped. The
|
|
274
|
+
* stream-completion (finalize) patch always flushes synchronously, and a
|
|
275
|
+
* pending frame is cancelled on `reset()`/`destroy()`. No effect when
|
|
276
|
+
* `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
|
|
277
|
+
*/
|
|
278
|
+
constructor(options = {}) {
|
|
279
|
+
this.pool = options.pool ?? getDefaultPool();
|
|
280
|
+
this.config = options.config;
|
|
281
|
+
this.onError = options.onError;
|
|
282
|
+
this.onBlock = options.onBlock;
|
|
283
|
+
this.coalesce = options.coalesce ?? false;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Lazily reserve this client's stream id and bind it to a pool worker. The
|
|
287
|
+
* SOLE place that calls pool.acquire() — so the worker is created on the FIRST
|
|
288
|
+
* worker-bound operation (append/finalize/reset/pipeFrom/whenReady), never at
|
|
289
|
+
* construct time. This is what makes `new BrookClient()` SSR-safe: nothing here
|
|
290
|
+
* runs during an SSR render (which only subscribes + reads the snapshot).
|
|
291
|
+
*
|
|
292
|
+
* Idempotent: once this.pw is set it returns it immediately and never
|
|
293
|
+
* re-acquires — this.pw is never nulled (destroy() deliberately keeps it so
|
|
294
|
+
* StrictMode's destroy()→reattach() on the SAME instance re-registers the same
|
|
295
|
+
* slot). Note: streamId/worker assignment now follows first-worker-bound-op
|
|
296
|
+
* order, not construction order — a client constructed first no longer
|
|
297
|
+
* necessarily owns the lowest streamId. This affects neither the pool cap nor
|
|
298
|
+
* multiplexing (pick() is unchanged and remains the only path to create()).
|
|
299
|
+
*/
|
|
300
|
+
ensureAcquired() {
|
|
301
|
+
if (this.pw && !this.pw.failed) return this.pw;
|
|
302
|
+
this.pw = null;
|
|
303
|
+
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
304
|
+
this.streamId = streamId;
|
|
305
|
+
this.pw = pw;
|
|
306
|
+
return pw;
|
|
307
|
+
}
|
|
308
|
+
get ready() {
|
|
309
|
+
return this.pw?.ready ?? false;
|
|
310
|
+
}
|
|
311
|
+
whenReady() {
|
|
312
|
+
const pw = this.ensureAcquired();
|
|
313
|
+
return this.pool.whenWorkerReady(pw);
|
|
314
|
+
}
|
|
315
|
+
// The config rides on the first message a stream sends; the worker applies it
|
|
316
|
+
// when it creates the parser. postMessage is FIFO per worker, so it always
|
|
317
|
+
// lands before any append is processed. Returns undefined after the first use.
|
|
318
|
+
firstConfig() {
|
|
319
|
+
if (this.configSent || !this.config) return void 0;
|
|
320
|
+
this.configSent = true;
|
|
321
|
+
return this.config;
|
|
322
|
+
}
|
|
323
|
+
append(chunk) {
|
|
324
|
+
const pw = this.ensureAcquired();
|
|
325
|
+
if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
|
|
326
|
+
this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
|
|
327
|
+
}
|
|
328
|
+
finalize() {
|
|
329
|
+
const pw = this.ensureAcquired();
|
|
330
|
+
this.finalizePending = true;
|
|
331
|
+
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Pipe a source straight in: read it to completion, `append()` each chunk,
|
|
335
|
+
* then `finalize()`. The LLM-native path — e.g.
|
|
336
|
+
* `await client.pipeFrom(await fetch("/api/chat"))`. Accepts:
|
|
337
|
+
* - a `Response` or its `ReadableStream<Uint8Array>` body (bytes; decoded
|
|
338
|
+
* with `TextDecoder({ stream: true })` so a multibyte sequence straddling
|
|
339
|
+
* a chunk boundary carries into the next read), or
|
|
340
|
+
* - an `AsyncIterable<string>` (e.g. an SSE delta generator) — string chunks
|
|
341
|
+
* appended verbatim.
|
|
342
|
+
*
|
|
343
|
+
* Pass `opts.signal` to supersede/cancel: the signal is checked on every
|
|
344
|
+
* iteration, so once aborted no further chunk is appended and **finalize is
|
|
345
|
+
* skipped** (a superseded stream must not finalize). For a byte source the
|
|
346
|
+
* reader is also `cancel()`'d to tear down the upstream. Resolves once
|
|
347
|
+
* finalized (or cleanly on abort); rejects if the source itself errors.
|
|
348
|
+
* Browser-only for byte sources (uses `TextDecoder`).
|
|
349
|
+
*/
|
|
350
|
+
async pipeFrom(source, opts) {
|
|
351
|
+
const signal = opts?.signal;
|
|
352
|
+
if (signal?.aborted) return;
|
|
353
|
+
if (!("getReader" in source) && !("body" in source)) {
|
|
354
|
+
for await (const chunk of source) {
|
|
355
|
+
if (signal?.aborted) return;
|
|
356
|
+
this.append(chunk);
|
|
357
|
+
}
|
|
358
|
+
if (!signal?.aborted) this.finalize();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const body = "body" in source ? source.body : source;
|
|
362
|
+
if (!body) {
|
|
363
|
+
this.finalize();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const reader = body.getReader();
|
|
367
|
+
const onAbort = () => {
|
|
368
|
+
reader.cancel().catch(() => {
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
372
|
+
const decoder = new TextDecoder();
|
|
373
|
+
try {
|
|
374
|
+
for (; ; ) {
|
|
375
|
+
const { done, value } = await reader.read();
|
|
376
|
+
if (signal?.aborted) return;
|
|
377
|
+
if (done) break;
|
|
378
|
+
if (value) this.append(decoder.decode(value, { stream: true }));
|
|
379
|
+
}
|
|
380
|
+
this.append(decoder.decode());
|
|
381
|
+
this.finalize();
|
|
382
|
+
} finally {
|
|
383
|
+
signal?.removeEventListener("abort", onAbort);
|
|
384
|
+
try {
|
|
385
|
+
reader.releaseLock();
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Drive the parser from a CONTROLLED full string instead of manual appends.
|
|
392
|
+
* Pass the whole document-so-far each time; setContent diffs it against the
|
|
393
|
+
* last value and does the minimal work:
|
|
394
|
+
* - **prefix-extension** (the streaming-growth case) → append only the new
|
|
395
|
+
* suffix, so committed blocks stay put and only the active tail re-parses;
|
|
396
|
+
* - **any other change** (e.g. a finished stream swapped for a re-processed
|
|
397
|
+
* final string) → reset + reparse the whole new string, keeping the
|
|
398
|
+
* current view on screen until the reparse lands: the document never
|
|
399
|
+
* blanks, scroll never moves, and blocks whose rendered content is
|
|
400
|
+
* unchanged keep their identity (and React keys) so only genuinely
|
|
401
|
+
* changed blocks re-render. An empty new string is an explicit clear and
|
|
402
|
+
* hard-resets immediately.
|
|
403
|
+
*
|
|
404
|
+
* This is the first-class bridge for UIs that hold a streaming message as a
|
|
405
|
+
* single growing string prop (the common React shape) — no hand-rolled diff,
|
|
406
|
+
* no readiness gate (appends before WASM is ready are buffered). Pass
|
|
407
|
+
* `{ done: true }` once the content is final to `finalize()` (idempotent within
|
|
408
|
+
* a generation; a content change *after* done reopens the stream via a fresh
|
|
409
|
+
* reparse, since a finalized parser is terminal and can't be appended to).
|
|
410
|
+
* Drive a given client with `setContent` *or* manual `append()`/`finalize()`,
|
|
411
|
+
* not both — they share the internal diff baseline.
|
|
412
|
+
*
|
|
413
|
+
* v1 note: the non-prefix path is a full reparse, not a partial rewind —
|
|
414
|
+
* committed blocks are frozen, so there is no truncate-to-offset. For the
|
|
415
|
+
* common case (append-growth + one end-of-stream swap) that is optimal. A
|
|
416
|
+
* transform that rewrites *earlier* bytes on every update is an anti-pattern
|
|
417
|
+
* here (it forces a reparse each tick); do that enrichment at render time via
|
|
418
|
+
* `components` instead, keeping the source append-only.
|
|
419
|
+
*/
|
|
420
|
+
setContent(content, opts) {
|
|
421
|
+
if (content !== this.lastContent) {
|
|
422
|
+
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
423
|
+
if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
|
|
424
|
+
this.softReset(this.getSnapshot());
|
|
425
|
+
}
|
|
426
|
+
this.append(content.slice(this.lastContent.length));
|
|
427
|
+
} else {
|
|
428
|
+
const displayed = this.getSnapshot();
|
|
429
|
+
if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
|
|
430
|
+
else this.reset();
|
|
431
|
+
this.append(content);
|
|
432
|
+
}
|
|
433
|
+
this.lastContent = content;
|
|
434
|
+
this.contentDone = false;
|
|
435
|
+
}
|
|
436
|
+
if (opts?.done && !this.contentDone) {
|
|
437
|
+
this.finalize();
|
|
438
|
+
this.contentDone = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
reset() {
|
|
442
|
+
const hadContent = this.getSnapshot().length > 0;
|
|
443
|
+
this.staleSnapshot = null;
|
|
444
|
+
this.staleTrimmed = false;
|
|
445
|
+
this.mergeCache = null;
|
|
446
|
+
this.resetParser();
|
|
447
|
+
if (hadContent) this.emit(true);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* setContent's divergence reset: rebuild the parser exactly like {@link reset},
|
|
451
|
+
* but keep `preserve` (the currently displayed view) on screen while the new
|
|
452
|
+
* content reparses. No notify fires here — subscribers keep reading the same
|
|
453
|
+
* snapshot reference until the first new-generation patch merges over it, so
|
|
454
|
+
* the swap is seamless: no empty frame, no container collapse, no scroll
|
|
455
|
+
* clamp, and blocks whose content survives the reprocess never re-render.
|
|
456
|
+
*/
|
|
457
|
+
softReset(preserve) {
|
|
458
|
+
this.staleSnapshot = preserve;
|
|
459
|
+
this.staleTrimmed = false;
|
|
460
|
+
this.idNamespace += ID_NAMESPACE_STRIDE;
|
|
461
|
+
this.resetParser();
|
|
462
|
+
this.mergeCache = { base: this.store.snapshot, trimmed: false, view: preserve };
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Finish a preserved-view divergence swap by making the merged view THE
|
|
466
|
+
* store: adopted ids become the committed keys, the merged array becomes the
|
|
467
|
+
* snapshot, and every scrap of merge state drops. From here getSnapshot() is
|
|
468
|
+
* a plain field read again (zero steady-state overhead) and the superseded
|
|
469
|
+
* generation's blocks are garbage — only one document stays in memory.
|
|
470
|
+
* Runs on the terminal (final) patch, which commits everything — if anything
|
|
471
|
+
* is somehow still open, the lazy merge simply stays live instead (the
|
|
472
|
+
* incremental reuse keeps it linear).
|
|
473
|
+
*/
|
|
474
|
+
collapseStale() {
|
|
475
|
+
if (this.store.active.length > 0) return;
|
|
476
|
+
const view = this.getSnapshot();
|
|
477
|
+
const committed = /* @__PURE__ */ new Map();
|
|
478
|
+
const committedOrder = new Array(view.length);
|
|
479
|
+
for (let i = 0; i < view.length; i++) {
|
|
480
|
+
committedOrder[i] = view[i].id;
|
|
481
|
+
committed.set(view[i].id, view[i]);
|
|
482
|
+
}
|
|
483
|
+
this.store = { committed, committedOrder, active: [], snapshot: view };
|
|
484
|
+
this.staleSnapshot = null;
|
|
485
|
+
this.staleTrimmed = false;
|
|
486
|
+
this.mergeCache = null;
|
|
487
|
+
}
|
|
488
|
+
// Shared generation teardown: swap in an empty store, zero the metrics and
|
|
489
|
+
// the setContent baseline, invalidate any coalesced frame, bump the epoch,
|
|
490
|
+
// and tell the worker to drop the parser. View concerns (stale preservation,
|
|
491
|
+
// subscriber notify) belong to the callers — reset() and softReset().
|
|
492
|
+
resetParser() {
|
|
493
|
+
this.store = emptyBlockStore();
|
|
494
|
+
this.appendedBytes = 0;
|
|
495
|
+
this.patchCount = 0;
|
|
496
|
+
this.totalParseMicros = 0;
|
|
497
|
+
this.lastPatchMs = 0;
|
|
498
|
+
this.firstAppendMs = 0;
|
|
499
|
+
this.retainedBytes = 0;
|
|
500
|
+
this.wasmMemoryBytes = 0;
|
|
501
|
+
this.lastContent = "";
|
|
502
|
+
this.contentDone = false;
|
|
503
|
+
this.cancelFrame();
|
|
504
|
+
this.finalizePending = false;
|
|
505
|
+
this.epoch += 1;
|
|
506
|
+
const pw = this.ensureAcquired();
|
|
507
|
+
this.pool.send(pw, { type: "reset", streamId: this.streamId, epoch: this.epoch });
|
|
508
|
+
}
|
|
509
|
+
destroy() {
|
|
510
|
+
if (!this.attached) return;
|
|
511
|
+
if (this.pw) this.pool.release(this.streamId, this.pw);
|
|
512
|
+
this.cancelFrame();
|
|
513
|
+
this.finalizePending = false;
|
|
514
|
+
this.listeners.clear();
|
|
515
|
+
this.attached = false;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Re-register with the pool after {@link destroy} so the client receives
|
|
519
|
+
* patches again. Needed only for React StrictMode's dev double-mount, where
|
|
520
|
+
* the renderer destroys on the simulated unmount then remounts the SAME
|
|
521
|
+
* client instance; apps don't normally call this. No-op if still attached.
|
|
522
|
+
*/
|
|
523
|
+
reattach() {
|
|
524
|
+
if (this.attached) return;
|
|
525
|
+
this.lastContent = "";
|
|
526
|
+
this.contentDone = false;
|
|
527
|
+
if (!this.pw) {
|
|
528
|
+
this.attached = true;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
|
|
532
|
+
this.attached = true;
|
|
533
|
+
this.configSent = false;
|
|
534
|
+
}
|
|
535
|
+
subscribe = (fn) => {
|
|
536
|
+
this.listeners.add(fn);
|
|
537
|
+
return () => this.listeners.delete(fn);
|
|
538
|
+
};
|
|
539
|
+
getSnapshot = () => {
|
|
540
|
+
const base = this.store.snapshot;
|
|
541
|
+
if (!this.staleSnapshot) return base;
|
|
542
|
+
const cache = this.mergeCache;
|
|
543
|
+
if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
|
|
544
|
+
const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
|
|
545
|
+
this.mergeCache = { base, trimmed: this.staleTrimmed, view };
|
|
546
|
+
return view;
|
|
547
|
+
};
|
|
548
|
+
/**
|
|
549
|
+
* Positional merge of the preserved pre-divergence view over the rebuilding
|
|
550
|
+
* store (see {@link softReset}). Per position:
|
|
551
|
+
* - identical committed block (html + kind + open + speculative) → the OLD
|
|
552
|
+
* block object, so its id and reference survive the swap and the block
|
|
553
|
+
* never re-renders (blocksEqual / the DOM keyed reconcile hold);
|
|
554
|
+
* - changed committed block → the new block CARRYING THE OLD BLOCK'S id, so
|
|
555
|
+
* the same keyed component re-renders in place and its state (pagination,
|
|
556
|
+
* expansion…) survives the swap; only a NET-NEW position (past the old
|
|
557
|
+
* document's end) takes a namespace-offset id, where a raw parser id
|
|
558
|
+
* could genuinely collide with a retained old id;
|
|
559
|
+
* - still-open block over old content → the old block (never a shrinking
|
|
560
|
+
* partial where complete content was already on screen); past the old
|
|
561
|
+
* document's end the live tail streams in as-is;
|
|
562
|
+
* - position the reparse hasn't reached → the old block, until the terminal
|
|
563
|
+
* patch sets `staleTrimmed` and the view clamps to the new length.
|
|
564
|
+
*
|
|
565
|
+
* LINEARITY: committed blocks are reference-stable across patches (the store
|
|
566
|
+
* contract), so a base entry pointer-equal to the previous merge's reproduces
|
|
567
|
+
* its previous decision without re-running the O(html) equality compare. Each
|
|
568
|
+
* block is string-compared exactly once — when it first commits — keeping a
|
|
569
|
+
* long post-divergence stream linear instead of quadratic. Only the active
|
|
570
|
+
* tail (fresh references each patch, small by design) re-compares per patch.
|
|
571
|
+
*/
|
|
572
|
+
mergeStale(base, prev) {
|
|
573
|
+
const stale = this.staleSnapshot;
|
|
574
|
+
const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
|
|
575
|
+
const view = new Array(len);
|
|
576
|
+
for (let i = 0; i < len; i++) {
|
|
577
|
+
const nb = i < base.length ? base[i] : void 0;
|
|
578
|
+
if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
|
|
579
|
+
view[i] = prev.view[i];
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const ob = i < stale.length ? stale[i] : void 0;
|
|
583
|
+
if (!nb) {
|
|
584
|
+
view[i] = ob;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (ob) {
|
|
588
|
+
if (nb.open && !this.staleTrimmed) {
|
|
589
|
+
view[i] = ob;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (nb.html === ob.html && nb.kind.type === ob.kind.type && nb.open === ob.open && nb.speculative === ob.speculative) {
|
|
593
|
+
view[i] = ob;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
view[i] = { ...nb, id: ob.id };
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
view[i] = { ...nb, id: this.idNamespace + nb.id };
|
|
600
|
+
}
|
|
601
|
+
return view;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Internal: a renderer with an `onRenderMetrics` hook calls this once per
|
|
605
|
+
* actual React block render so `getMetrics().renderCount` aggregates churn.
|
|
606
|
+
* No-op cost when no hook is wired (it is simply never called). Not part of
|
|
607
|
+
* the public API surface — the underscore marks it renderer-internal.
|
|
608
|
+
*/
|
|
609
|
+
__noteRender() {
|
|
610
|
+
this.renderCount++;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Internal: the DOM renderer calls this once per actual node rebuild (the
|
|
614
|
+
* changed-block branch) when an `onRenderMetrics` hook is wired, so
|
|
615
|
+
* `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
|
|
616
|
+
*/
|
|
617
|
+
__noteRebuild() {
|
|
618
|
+
this.rebuildCount++;
|
|
619
|
+
}
|
|
620
|
+
getMetrics() {
|
|
621
|
+
const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
|
|
622
|
+
return {
|
|
623
|
+
bytes: this.appendedBytes,
|
|
624
|
+
patches: this.patchCount,
|
|
625
|
+
meanParseMicros: this.patchCount > 0 ? this.totalParseMicros / this.patchCount : 0,
|
|
626
|
+
totalParseMs: this.totalParseMicros / 1e3,
|
|
627
|
+
throughputKBs: this.appendedBytes / 1024 / (elapsed / 1e3),
|
|
628
|
+
committedBlocks: this.store.committed.size,
|
|
629
|
+
activeBlocks: this.store.active.length,
|
|
630
|
+
lastPatchAgoMs: this.lastPatchMs === 0 ? 0 : performance.now() - this.lastPatchMs,
|
|
631
|
+
retainedBytes: this.retainedBytes,
|
|
632
|
+
// NOTE: with the worker pool, this is the *shared* worker's WASM heap —
|
|
633
|
+
// clients on the same worker report the same number. Use Math.max (not
|
|
634
|
+
// sum) when aggregating across clients; summing double-counts.
|
|
635
|
+
wasmMemoryBytes: this.wasmMemoryBytes,
|
|
636
|
+
// Render-path churn (0 unless an onRenderMetrics hook is wired into a
|
|
637
|
+
// renderer): renderCount = React block-body renders, rebuildCount = DOM
|
|
638
|
+
// node rebuilds. Committed blocks memo-skip, so they contribute once.
|
|
639
|
+
renderCount: this.renderCount,
|
|
640
|
+
rebuildCount: this.rebuildCount
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* A heading outline of the current snapshot (committed + active), in document
|
|
645
|
+
* order — for a table of contents. Works mid-stream; entries appear as their
|
|
646
|
+
* headings stream in. The `id` is stable, so a built ToC won't re-key.
|
|
647
|
+
*/
|
|
648
|
+
outline() {
|
|
649
|
+
const out = [];
|
|
650
|
+
for (const b of this.getSnapshot()) {
|
|
651
|
+
if (b.kind.type === "Heading") {
|
|
652
|
+
const d = b.kind.data;
|
|
653
|
+
const level = typeof d === "number" ? d : d?.level ?? 1;
|
|
654
|
+
out.push({ level, text: htmlToText(b.html), id: b.id });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The rendered document as plain text — tags stripped, entities decoded,
|
|
661
|
+
* blocks separated by blank lines. Derived from the rendered HTML (the source
|
|
662
|
+
* markdown is parsed away in WASM and not retained client-side), so it is a
|
|
663
|
+
* readable approximation for search indexing / summaries, not a round-trip of
|
|
664
|
+
* the original source.
|
|
665
|
+
*/
|
|
666
|
+
toPlaintext() {
|
|
667
|
+
const parts = [];
|
|
668
|
+
for (const b of this.getSnapshot()) {
|
|
669
|
+
const t = htmlToText(b.html);
|
|
670
|
+
if (t) parts.push(t);
|
|
671
|
+
}
|
|
672
|
+
return parts.join("\n\n");
|
|
673
|
+
}
|
|
674
|
+
onMessage(msg) {
|
|
675
|
+
switch (msg.type) {
|
|
676
|
+
case "patch": {
|
|
677
|
+
if (msg.epoch !== void 0 && msg.epoch < this.epoch) break;
|
|
678
|
+
let patch;
|
|
679
|
+
try {
|
|
680
|
+
patch = JSON.parse(msg.patch);
|
|
681
|
+
} catch (e) {
|
|
682
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
683
|
+
if (this.onError) this.onError({ message: `brookmd: malformed patch (${message})` });
|
|
684
|
+
else console.error("brookmd: malformed patch:", message);
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
applyPatch(this.store, patch);
|
|
688
|
+
if (msg.final === true && this.staleSnapshot) {
|
|
689
|
+
this.staleTrimmed = true;
|
|
690
|
+
this.mergeCache = null;
|
|
691
|
+
this.collapseStale();
|
|
692
|
+
}
|
|
693
|
+
this.appendedBytes = msg.appendedBytes;
|
|
694
|
+
this.totalParseMicros += msg.parseMicros;
|
|
695
|
+
this.retainedBytes = msg.retainedBytes;
|
|
696
|
+
this.wasmMemoryBytes = msg.wasmMemoryBytes;
|
|
697
|
+
this.patchCount += 1;
|
|
698
|
+
this.lastPatchMs = performance.now();
|
|
699
|
+
const sync = this.finalizePending || msg.final === true;
|
|
700
|
+
this.finalizePending = false;
|
|
701
|
+
this.emit(sync);
|
|
702
|
+
if (this.onBlock) {
|
|
703
|
+
for (const b of patch.newly_committed) this.onBlock(b);
|
|
704
|
+
}
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case "error":
|
|
708
|
+
if (this.onError) {
|
|
709
|
+
this.onError({ message: msg.message, fatal: msg.fatal });
|
|
710
|
+
} else {
|
|
711
|
+
console.error("brookmd worker error:", msg.message);
|
|
712
|
+
}
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Notify subscribers of a new snapshot.
|
|
718
|
+
*
|
|
719
|
+
* With `coalesce` off (default) this is fully synchronous, exactly as before.
|
|
720
|
+
* With it on and `requestAnimationFrame` available, a normal emit only
|
|
721
|
+
* *schedules* a single per-frame flush — repeated intra-frame emits collapse
|
|
722
|
+
* into one notify. `sync` forces an immediate flush (stream completion / reset)
|
|
723
|
+
* and cancels any frame already pending so the snapshot is delivered once.
|
|
724
|
+
*/
|
|
725
|
+
emit(sync = false) {
|
|
726
|
+
if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
|
|
727
|
+
if (this.rafHandle !== null) return;
|
|
728
|
+
this.rafHandle = requestAnimationFrame(() => {
|
|
729
|
+
this.rafHandle = null;
|
|
730
|
+
this.flushNow();
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.cancelFrame();
|
|
735
|
+
this.flushNow();
|
|
736
|
+
}
|
|
737
|
+
flushNow() {
|
|
738
|
+
for (const fn of this.listeners) fn();
|
|
739
|
+
}
|
|
740
|
+
cancelFrame() {
|
|
741
|
+
if (this.rafHandle !== null) {
|
|
742
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
|
|
743
|
+
this.rafHandle = null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
export {
|
|
748
|
+
BrookClient,
|
|
749
|
+
BrookPool,
|
|
750
|
+
__resetDefaultPool,
|
|
751
|
+
applyPatch,
|
|
752
|
+
emptyBlockStore,
|
|
753
|
+
getDefaultPool
|
|
754
|
+
};
|