flux-md 0.16.2 → 0.18.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +950 -0
  2. package/README.md +111 -82
  3. package/dist/block-props.d.ts +18 -0
  4. package/dist/block-props.js +75 -0
  5. package/dist/client.d.ts +311 -0
  6. package/{src/client.ts → dist/client.js} +143 -325
  7. package/dist/dom.d.ts +110 -0
  8. package/dist/dom.js +576 -0
  9. package/dist/element.d.ts +20 -0
  10. package/dist/element.js +287 -0
  11. package/dist/hi.d.ts +12 -0
  12. package/{src/hi.ts → dist/hi.js} +42 -74
  13. package/dist/html-to-react.d.ts +40 -0
  14. package/dist/html-to-react.js +344 -0
  15. package/{src/index.ts → dist/index.d.ts} +5 -25
  16. package/dist/index.js +16 -0
  17. package/dist/morph.d.ts +28 -0
  18. package/dist/morph.js +166 -0
  19. package/dist/react.d.ts +204 -0
  20. package/dist/react.js +490 -0
  21. package/dist/renderers/CodeBlock.d.ts +7 -0
  22. package/dist/renderers/CodeBlock.js +75 -0
  23. package/dist/renderers/Math.d.ts +14 -0
  24. package/dist/renderers/Math.js +15 -0
  25. package/dist/renderers/Mermaid.d.ts +13 -0
  26. package/dist/renderers/Mermaid.js +15 -0
  27. package/dist/server-react.d.ts +32 -0
  28. package/dist/server-react.js +48 -0
  29. package/dist/server.d.ts +31 -0
  30. package/dist/server.js +81 -0
  31. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  32. package/dist/solid.js +54 -0
  33. package/dist/svelte.d.ts +80 -0
  34. package/dist/svelte.js +59 -0
  35. package/dist/types-core.d.ts +382 -0
  36. package/dist/types-core.js +0 -0
  37. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  38. package/dist/types-react.js +0 -0
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.js +2 -0
  41. package/dist/vue.d.ts +94 -0
  42. package/dist/vue.js +79 -0
  43. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  44. package/{src → dist}/wasm/flux_md_core.js +50 -55
  45. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  46. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  47. package/dist/worker-core.d.ts +65 -0
  48. package/dist/worker-core.js +154 -0
  49. package/dist/worker.d.ts +1 -0
  50. package/dist/worker.js +48 -0
  51. package/package.json +25 -19
  52. package/src/block-props.ts +0 -141
  53. package/src/dom.ts +0 -946
  54. package/src/element.ts +0 -400
  55. package/src/html-to-react.ts +0 -455
  56. package/src/morph.ts +0 -253
  57. package/src/react.tsx +0 -1020
  58. package/src/renderers/CodeBlock.tsx +0 -116
  59. package/src/renderers/Math.tsx +0 -28
  60. package/src/renderers/Mermaid.tsx +0 -27
  61. package/src/server.tsx +0 -221
  62. package/src/svelte.ts +0 -179
  63. package/src/types-core.ts +0 -398
  64. package/src/types.ts +0 -7
  65. package/src/vue.ts +0 -184
  66. package/src/wasm/flux_md_core_bg.wasm +0 -0
  67. package/src/worker-core.ts +0 -174
  68. package/src/worker.ts +0 -72
  69. /package/{src → dist}/styles.css +0 -0
@@ -1,113 +1,36 @@
1
- import type { Block, FromWorker, ParserConfig, Patch, ToWorker, WorkerLike } from "./types-core";
2
-
3
- /**
4
- * The ordered-block store backing a stream, extracted as a pure function so
5
- * its reference-stability contract is testable without a Worker.
6
- *
7
- * **The contract that prevents extra React re-renders:** a block, once
8
- * committed, is never re-sent by the parser, so `applyPatch` never replaces it
9
- * in the map. Its object reference stays identical across every later patch —
10
- * which is exactly what `blocksEqual` (the BlockView memo) checks, so committed
11
- * blocks never re-render (and never re-parse) as the stream grows. Only the
12
- * `active` tail gets fresh references each patch, and only it re-renders.
13
- */
14
- export interface BlockStore {
15
- committed: Map<number, Block>;
16
- committedOrder: number[];
17
- active: Block[];
18
- snapshot: Block[];
1
+ function emptyBlockStore() {
2
+ return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
19
3
  }
20
-
21
- export function emptyBlockStore(): BlockStore {
22
- return { committed: new Map(), committedOrder: [], active: [], snapshot: [] };
4
+ function htmlToText(html) {
5
+ return html.replace(/<[^>]*>/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim();
23
6
  }
24
-
25
- /** A heading entry for building a table of contents — see {@link FluxClient.outline}. */
26
- export interface OutlineEntry {
27
- /** Heading level 1–6. */
28
- level: number;
29
- /** Plain-text heading content (tags stripped, entities decoded). */
30
- text: string;
31
- /** Stable block id — usable as a scroll target / React key. */
32
- id: number;
33
- }
34
-
35
- /** Strip tags (→ space) and decode the small entity set the core emits, then
36
- * collapse whitespace. INVARIANT: the simple `<[^>]*>` strip is only safe
37
- * because every input here is HTML the Rust core produced via escape_html /
38
- * escape_attr — which escape `>` inside attribute values, so no `>` ever
39
- * appears except as a real tag close. This must NOT be fed externally-authored
40
- * HTML. `&amp;` decodes last so `&amp;lt;` → `&lt;`, not `<`. */
41
- function htmlToText(html: string): string {
42
- return html
43
- .replace(/<[^>]*>/g, " ")
44
- .replace(/&lt;/g, "<")
45
- .replace(/&gt;/g, ">")
46
- .replace(/&quot;/g, '"')
47
- .replace(/&#39;/g, "'")
48
- .replace(/&amp;/g, "&")
49
- .replace(/\s+/g, " ")
50
- .trim();
51
- }
52
-
53
- export function applyPatch(store: BlockStore, patch: Patch): void {
7
+ function applyPatch(store, patch) {
54
8
  for (const b of patch.newly_committed) {
55
9
  if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
56
10
  store.committed.set(b.id, b);
57
11
  }
58
12
  store.active = patch.active;
59
- // Fresh array each patch (immutable for React reference checks), but the
60
- // committed entries inside it are the same object references as before.
61
- const next: Block[] = new Array(store.committedOrder.length + store.active.length);
13
+ const next = new Array(store.committedOrder.length + store.active.length);
62
14
  for (let i = 0; i < store.committedOrder.length; i++) {
63
- next[i] = store.committed.get(store.committedOrder[i])!;
15
+ next[i] = store.committed.get(store.committedOrder[i]);
64
16
  }
65
17
  for (let i = 0; i < store.active.length; i++) {
66
18
  next[store.committedOrder.length + i] = store.active[i];
67
19
  }
68
20
  store.snapshot = next;
69
21
  }
70
-
71
- // --------------------------------------------------------------------------
72
- // Worker pool
73
- // --------------------------------------------------------------------------
74
-
75
- interface PoolWorker {
76
- worker: WorkerLike;
77
- ready: boolean;
78
- /** Set once WASM init fails; whenWorkerReady rejects with this thereafter. */
79
- failed: Error | null;
80
- streamCount: number;
81
- /** Live stream ids on this worker — so a fatal failure can notify each one. */
82
- streamIds: Set<number>;
83
- readyWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }>;
84
- }
85
-
86
- /**
87
- * A pool of Web Workers, each multiplexing many `FluxParser`s keyed by stream
88
- * id. This is what lets flux-md scale past `hardwareConcurrency` concurrent
89
- * streams without oversubscribing OS threads: 50 streams share (at most) the
90
- * cap's worth of workers instead of spawning 50.
91
- *
92
- * Worker creation is **lazy and load-aware**: while under the cap, each new
93
- * stream gets its own worker (so 1 stream = 1 worker, identical to the old
94
- * behavior); once at the cap, new streams attach to the least-loaded worker.
95
- *
96
- * The constructor injects a `WorkerLike` factory so the routing and lifecycle
97
- * logic is unit-testable with a fake worker — no real Worker or WASM needed.
98
- */
99
- export class FluxPool {
100
- private workers: PoolWorker[] = [];
101
- private handlers = new Map<number, (msg: FromWorker) => void>();
102
- private nextStreamId = 1;
103
-
104
- constructor(
105
- private factory: () => WorkerLike,
106
- private cap: number,
107
- ) {}
108
-
22
+ class FluxPool {
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;
109
32
  /** Reserve a stream id and assign a worker, registering its message handler. */
110
- acquire(handler: (msg: FromWorker) => void): { streamId: number; pw: PoolWorker } {
33
+ acquire(handler) {
111
34
  const streamId = this.nextStreamId++;
112
35
  const pw = this.pick();
113
36
  pw.streamCount++;
@@ -115,42 +38,36 @@ export class FluxPool {
115
38
  this.handlers.set(streamId, handler);
116
39
  return { streamId, pw };
117
40
  }
118
-
119
41
  /** Free a stream's parser in its worker; keep the worker warm for siblings. */
120
- release(streamId: number, pw: PoolWorker): void {
42
+ release(streamId, pw) {
121
43
  this.handlers.delete(streamId);
122
44
  pw.streamIds.delete(streamId);
123
45
  pw.streamCount = Math.max(0, pw.streamCount - 1);
124
46
  try {
125
47
  pw.worker.postMessage({ type: "dispose", streamId });
126
48
  } catch {
127
- /* worker already gone */
128
49
  }
129
50
  }
130
-
131
51
  /** Inverse of {@link release}: re-register a stream's handler so it receives
132
52
  * patches again. For React StrictMode's dev double-mount, which destroys a
133
53
  * client on the simulated unmount and remounts the SAME instance. The worker
134
54
  * lazily recreates the disposed parser on the next append. */
135
- reattach(streamId: number, pw: PoolWorker, handler: (msg: FromWorker) => void): void {
55
+ reattach(streamId, pw, handler) {
136
56
  if (!this.handlers.has(streamId)) {
137
57
  pw.streamCount++;
138
58
  pw.streamIds.add(streamId);
139
59
  }
140
60
  this.handlers.set(streamId, handler);
141
61
  }
142
-
143
- send(pw: PoolWorker, msg: ToWorker): void {
62
+ send(pw, msg) {
144
63
  pw.worker.postMessage(msg);
145
64
  }
146
-
147
65
  /** Resolves when the given worker has finished WASM init; rejects if it failed. */
148
- whenWorkerReady(pw: PoolWorker): Promise<void> {
66
+ whenWorkerReady(pw) {
149
67
  if (pw.ready) return Promise.resolve();
150
68
  if (pw.failed) return Promise.reject(pw.failed);
151
69
  return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
152
70
  }
153
-
154
71
  /**
155
72
  * Eagerly spin up one worker so WASM init starts BEFORE the first stream —
156
73
  * taking the one-time init off the first-token critical path (e.g. call
@@ -160,57 +77,51 @@ export class FluxPool {
160
77
  * finished initializing WASM; rejects if init fails fatally. Browser-only (it
161
78
  * constructs a `Worker`).
162
79
  */
163
- warm(): Promise<void> {
80
+ warm() {
164
81
  const live = this.workers.filter((w) => !w.failed);
165
82
  const pw = live[0] ?? this.create();
166
83
  return this.whenWorkerReady(pw);
167
84
  }
168
-
169
85
  /** Terminate every worker (test teardown / full shutdown). */
170
- disposeAll(): void {
86
+ disposeAll() {
171
87
  for (const pw of this.workers) {
172
88
  try {
173
89
  pw.worker.terminate();
174
90
  } catch {
175
- /* ignore */
176
91
  }
177
92
  }
178
93
  this.workers = [];
179
94
  this.handlers.clear();
180
95
  }
181
-
182
- get workerCount(): number {
96
+ get workerCount() {
183
97
  return this.workers.length;
184
98
  }
185
-
186
99
  // Create a new worker while under cap and every live worker is busy; otherwise
187
100
  // attach to the least-loaded LIVE worker. A fatally-failed worker is never
188
101
  // handed out (a stream on it would post into a dead worker and hang) — it is
189
102
  // retained only to reject outstanding whenWorkerReady waiters.
190
- private pick(): PoolWorker {
103
+ pick() {
191
104
  const live = this.workers.filter((w) => !w.failed);
192
105
  if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
193
106
  return this.create();
194
107
  }
195
108
  if (live.length === 0) return this.create();
196
- return live.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
109
+ return live.reduce((a, b) => b.streamCount < a.streamCount ? b : a);
197
110
  }
198
-
199
- private create(): PoolWorker {
200
- const pw: PoolWorker = {
111
+ create() {
112
+ const pw = {
201
113
  worker: this.factory(),
202
114
  ready: false,
203
115
  failed: null,
204
116
  streamCount: 0,
205
- streamIds: new Set(),
206
- readyWaiters: [],
117
+ streamIds: /* @__PURE__ */ new Set(),
118
+ readyWaiters: []
207
119
  };
208
120
  pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
209
121
  this.workers.push(pw);
210
122
  return pw;
211
123
  }
212
-
213
- private onMessage(pw: PoolWorker, msg: FromWorker): void {
124
+ onMessage(pw, msg) {
214
125
  if (msg.type === "ready") {
215
126
  pw.ready = true;
216
127
  const waiters = pw.readyWaiters;
@@ -219,10 +130,6 @@ export class FluxPool {
219
130
  return;
220
131
  }
221
132
  if (msg.type === "error" && msg.fatal) {
222
- // A fatal (WASM-init) failure dooms every stream on this worker. Reject
223
- // anyone awaiting readiness, then notify each live stream's client so its
224
- // onError fires — the message carries no real streamId to route by. The
225
- // worker is kept only to reject those waiters; pick() never reuses it.
226
133
  const err = new Error(msg.message);
227
134
  pw.failed = err;
228
135
  const waiters = pw.readyWaiters;
@@ -231,91 +138,63 @@ export class FluxPool {
231
138
  try {
232
139
  w.reject(err);
233
140
  } catch {
234
- /* a waiter's rejection handler is the caller's problem, not ours */
235
141
  }
236
142
  }
237
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);
238
150
  return;
239
151
  }
240
152
  this.dispatch(msg.streamId, msg);
241
153
  }
242
-
243
154
  // Route a message to a stream's handler, isolating a throwing client callback
244
155
  // (e.g. a user-supplied onError) so it can neither break the worker message
245
156
  // loop nor starve sibling streams sharing this worker.
246
- private dispatch(streamId: number, msg: FromWorker): void {
157
+ dispatch(streamId, msg) {
247
158
  try {
248
159
  this.handlers.get(streamId)?.(msg);
249
160
  } catch (e) {
250
- // eslint-disable-next-line no-console
251
161
  console.error("flux: stream message handler threw", e);
252
162
  }
253
163
  }
254
164
  }
255
-
256
- function poolCap(): number {
165
+ function poolCap() {
257
166
  const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
258
167
  return Math.min(hc || 4, 8);
259
168
  }
260
-
261
- let defaultPool: FluxPool | null = null;
262
-
263
- /** The process-wide default pool every `FluxClient` shares unless given one. */
264
- export function getDefaultPool(): FluxPool {
169
+ let defaultPool = null;
170
+ function getDefaultPool() {
265
171
  if (!defaultPool) {
266
172
  defaultPool = new FluxPool(
267
- () => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike,
268
- poolCap(),
173
+ () => new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
174
+ poolCap()
269
175
  );
270
176
  }
271
177
  return defaultPool;
272
178
  }
273
-
274
- /** TEST-ONLY: drop the process-wide default pool so the next {@link getDefaultPool}
275
- * rebuilds it (lazily, with the current global `Worker`). Lets a test file that
276
- * drives the default pool start from a clean, deterministic state regardless of
277
- * which other file warmed it first in bun's shared test process. Not part of the
278
- * public API and a no-op for normal runtime use. */
279
- export function __resetDefaultPool(): void {
179
+ function __resetDefaultPool() {
280
180
  defaultPool = null;
281
181
  }
282
-
283
- // --------------------------------------------------------------------------
284
- // Client
285
- // --------------------------------------------------------------------------
286
-
287
- /**
288
- * Subscriber-driven store backing a single streaming parser. Each client owns
289
- * one stream within a shared {@link FluxPool}; many clients multiplex over a
290
- * small set of workers (see the pool for the scaling story).
291
- *
292
- * The store exposes:
293
- * - subscribe(listener): for React's useSyncExternalStore
294
- * - getSnapshot(): the current ordered list of blocks
295
- * - getMetrics(): per-stream perf metrics
296
- *
297
- * Mutation methods:
298
- * - append(chunk): forward to the worker
299
- * - finalize(): mark the stream done
300
- * - reset(): start fresh
301
- */
302
- export class FluxClient {
303
- private pool: FluxPool;
304
- private pw: PoolWorker | null = null;
305
- private streamId = 0;
306
- private config?: ParserConfig;
307
- private configSent = false;
308
- private listeners = new Set<() => void>();
309
- private store: BlockStore = emptyBlockStore();
310
- private onError?: (err: { message: string; fatal?: boolean }) => void;
311
- private onBlock?: (block: Block) => void;
312
- private attached = true;
182
+ class FluxClient {
183
+ pool;
184
+ pw = null;
185
+ streamId = 0;
186
+ config;
187
+ configSent = false;
188
+ listeners = /* @__PURE__ */ new Set();
189
+ store = emptyBlockStore();
190
+ onError;
191
+ onBlock;
192
+ attached = true;
313
193
  // Diff baseline for setContent(): the full string fed in so far, and whether
314
194
  // it has been finalized. Cleared by reset()/reattach() (the worker drops the
315
195
  // parser there, so the baseline is stale and the document must be re-fed).
316
- private lastContent = "";
317
- private contentDone = false;
318
-
196
+ lastContent = "";
197
+ contentDone = false;
319
198
  // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
320
199
  // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
321
200
  // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
@@ -324,26 +203,29 @@ export class FluxClient {
324
203
  // reference-stable, so a dropped intermediate notify only skips a tail-only
325
204
  // render that the next flush supersedes. The finalize/done patch is exempt and
326
205
  // flushes synchronously, never deferred to the next frame.
327
- private coalesce = false;
328
- private rafHandle: number | null = null;
206
+ coalesce = false;
207
+ rafHandle = null;
329
208
  // Set by finalize(); the next patch's emit flushes synchronously (a 'done'
330
- // notification must not be deferred a frame) and clears it.
331
- private finalizePending = false;
332
-
209
+ // notification must not be deferred a frame) and clears it. Belt-and-suspenders
210
+ // alongside the per-patch `final` flag, which is the authoritative signal.
211
+ finalizePending = false;
212
+ // Stream generation, bumped on reset(). Stamped on every worker message and
213
+ // echoed back on each patch; a patch whose epoch is older than this is a
214
+ // pre-reset straggler and is dropped before it can repopulate the cleared store.
215
+ epoch = 0;
333
216
  // Perf
334
- private appendedBytes = 0;
335
- private patchCount = 0;
336
- private totalParseMicros = 0;
337
- private lastPatchMs = 0;
338
- private firstAppendMs = 0;
339
- private retainedBytes = 0;
340
- private wasmMemoryBytes = 0;
217
+ appendedBytes = 0;
218
+ patchCount = 0;
219
+ totalParseMicros = 0;
220
+ lastPatchMs = 0;
221
+ firstAppendMs = 0;
222
+ retainedBytes = 0;
223
+ wasmMemoryBytes = 0;
341
224
  // Render-path observability (advanced ONLY when an onRenderMetrics hook is
342
225
  // wired into a renderer; zero-cost otherwise). renderCount = React BlockView
343
226
  // body renders; rebuildCount = DOM node rebuilds.
344
- private renderCount = 0;
345
- private rebuildCount = 0;
346
-
227
+ renderCount = 0;
228
+ rebuildCount = 0;
347
229
  /**
348
230
  * @param options.pool worker pool to join (defaults to the shared
349
231
  * process-wide pool — pass a dedicated `FluxPool` only for isolation).
@@ -365,22 +247,13 @@ export class FluxClient {
365
247
  * pending frame is cancelled on `reset()`/`destroy()`. No effect when
366
248
  * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
367
249
  */
368
- constructor(
369
- options: {
370
- pool?: FluxPool;
371
- config?: ParserConfig;
372
- onError?: (err: { message: string; fatal?: boolean }) => void;
373
- onBlock?: (block: Block) => void;
374
- coalesce?: boolean;
375
- } = {},
376
- ) {
250
+ constructor(options = {}) {
377
251
  this.pool = options.pool ?? getDefaultPool();
378
252
  this.config = options.config;
379
253
  this.onError = options.onError;
380
254
  this.onBlock = options.onBlock;
381
255
  this.coalesce = options.coalesce ?? false;
382
256
  }
383
-
384
257
  /**
385
258
  * Lazily reserve this client's stream id and bind it to a pool worker. The
386
259
  * SOLE place that calls pool.acquire() — so the worker is created on the FIRST
@@ -396,46 +269,39 @@ export class FluxClient {
396
269
  * necessarily owns the lowest streamId. This affects neither the pool cap nor
397
270
  * multiplexing (pick() is unchanged and remains the only path to create()).
398
271
  */
399
- private ensureAcquired(): PoolWorker {
400
- if (this.pw) return this.pw;
272
+ ensureAcquired() {
273
+ if (this.pw && !this.pw.failed) return this.pw;
274
+ this.pw = null;
401
275
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
402
276
  this.streamId = streamId;
403
277
  this.pw = pw;
404
278
  return pw;
405
279
  }
406
-
407
- get ready(): boolean {
280
+ get ready() {
408
281
  return this.pw?.ready ?? false;
409
282
  }
410
-
411
- whenReady(): Promise<void> {
283
+ whenReady() {
412
284
  const pw = this.ensureAcquired();
413
285
  return this.pool.whenWorkerReady(pw);
414
286
  }
415
-
416
287
  // The config rides on the first message a stream sends; the worker applies it
417
288
  // when it creates the parser. postMessage is FIFO per worker, so it always
418
289
  // lands before any append is processed. Returns undefined after the first use.
419
- private firstConfig(): ParserConfig | undefined {
420
- if (this.configSent || !this.config) return undefined;
290
+ firstConfig() {
291
+ if (this.configSent || !this.config) return void 0;
421
292
  this.configSent = true;
422
293
  return this.config;
423
294
  }
424
-
425
- append(chunk: string) {
295
+ append(chunk) {
426
296
  const pw = this.ensureAcquired();
427
297
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
428
- this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
298
+ this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
429
299
  }
430
-
431
300
  finalize() {
432
301
  const pw = this.ensureAcquired();
433
- // The terminal patch this triggers must reach subscribers synchronously, not
434
- // a frame late — mark it so the next emit flushes now even under coalescing.
435
302
  this.finalizePending = true;
436
- this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
303
+ this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
437
304
  }
438
-
439
305
  /**
440
306
  * Pipe a source straight in: read it to completion, `append()` each chunk,
441
307
  * then `finalize()`. The LLM-native path — e.g.
@@ -453,60 +319,46 @@ export class FluxClient {
453
319
  * finalized (or cleanly on abort); rejects if the source itself errors.
454
320
  * Browser-only for byte sources (uses `TextDecoder`).
455
321
  */
456
- async pipeFrom(
457
- source: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
458
- opts?: { signal?: AbortSignal },
459
- ): Promise<void> {
322
+ async pipeFrom(source, opts) {
460
323
  const signal = opts?.signal;
461
-
462
- if (signal?.aborted) return; // already superseded before we started
463
-
464
- // AsyncIterable<string> (SSE deltas, generators). Detected by elimination:
465
- // a ReadableStream has `getReader`, a Response has `body` — neither here.
324
+ if (signal?.aborted) return;
466
325
  if (!("getReader" in source) && !("body" in source)) {
467
- for await (const chunk of source as AsyncIterable<string>) {
468
- if (signal?.aborted) return; // superseded/unmounted: drop late chunks, no finalize
326
+ for await (const chunk of source) {
327
+ if (signal?.aborted) return;
469
328
  this.append(chunk);
470
329
  }
471
330
  if (!signal?.aborted) this.finalize();
472
331
  return;
473
332
  }
474
-
475
- // Byte source: a Response (use its body) or a ReadableStream directly.
476
333
  const body = "body" in source ? source.body : source;
477
334
  if (!body) {
478
- // An empty Response body (e.g. 204) is a completed, empty stream.
479
335
  this.finalize();
480
336
  return;
481
337
  }
482
338
  const reader = body.getReader();
483
- // A pending read() can't observe `aborted` until the next chunk; cancel()
484
- // on abort tears down the upstream and resolves the pending read so the
485
- // loop's post-read check fires and bails without finalizing.
486
339
  const onAbort = () => {
487
- reader.cancel().catch(() => {});
340
+ reader.cancel().catch(() => {
341
+ });
488
342
  };
489
343
  signal?.addEventListener("abort", onAbort, { once: true });
490
344
  const decoder = new TextDecoder();
491
345
  try {
492
- for (;;) {
346
+ for (; ; ) {
493
347
  const { done, value } = await reader.read();
494
- if (signal?.aborted) return; // superseded: no finalize (cancel already fired)
348
+ if (signal?.aborted) return;
495
349
  if (done) break;
496
350
  if (value) this.append(decoder.decode(value, { stream: true }));
497
351
  }
498
- this.append(decoder.decode()); // flush any trailing partial sequence
352
+ this.append(decoder.decode());
499
353
  this.finalize();
500
354
  } finally {
501
355
  signal?.removeEventListener("abort", onAbort);
502
356
  try {
503
357
  reader.releaseLock();
504
358
  } catch {
505
- /* already released (e.g. by cancel) */
506
359
  }
507
360
  }
508
361
  }
509
-
510
362
  /**
511
363
  * Drive the parser from a CONTROLLED full string instead of manual appends.
512
364
  * Pass the whole document-so-far each time; setContent diffs it against the
@@ -532,17 +384,12 @@ export class FluxClient {
532
384
  * here (it forces a reparse each tick); do that enrichment at render time via
533
385
  * `components` instead, keeping the source append-only.
534
386
  */
535
- setContent(content: string, opts?: { done?: boolean }) {
387
+ setContent(content, opts) {
536
388
  if (content !== this.lastContent) {
537
- // Fast path appends the delta into the EXISTING parser — but a parser that
538
- // was already finalized ({ done: true }) is terminal: the core drops any
539
- // further append. So gate the fast path on !contentDone; reopening a
540
- // finalized stream (or any divergence) falls through to reset()+reparse,
541
- // which frees the dead parser and rebuilds a fresh one.
542
389
  if (!this.contentDone && content.startsWith(this.lastContent)) {
543
390
  this.append(content.slice(this.lastContent.length));
544
391
  } else {
545
- this.reset(); // diverged, or reopening a finalized stream — rebuild
392
+ this.reset();
546
393
  this.append(content);
547
394
  }
548
395
  this.lastContent = content;
@@ -553,12 +400,7 @@ export class FluxClient {
553
400
  this.contentDone = true;
554
401
  }
555
402
  }
556
-
557
403
  reset() {
558
- // Only notify subscribers if there was content to clear: resetting an
559
- // already-empty store leaves the view empty either way, so skip the no-op
560
- // emit (which would otherwise drive every subscriber through a wasted,
561
- // output-identical render pass).
562
404
  const hadContent = this.store.snapshot.length > 0;
563
405
  this.store = emptyBlockStore();
564
406
  this.appendedBytes = 0;
@@ -568,33 +410,23 @@ export class FluxClient {
568
410
  this.firstAppendMs = 0;
569
411
  this.retainedBytes = 0;
570
412
  this.wasmMemoryBytes = 0;
571
- this.lastContent = ""; // setContent baseline: the worker drops the parser here
413
+ this.lastContent = "";
572
414
  this.contentDone = false;
573
- // A frame coalesced from the just-cleared stream is now stale — cancel it so
574
- // it can't fire a notify referencing content reset() just dropped.
575
415
  this.cancelFrame();
576
416
  this.finalizePending = false;
577
- // Same streamId + worker — the worker frees and lazily recreates the parser.
417
+ this.epoch += 1;
578
418
  const pw = this.ensureAcquired();
579
- this.pool.send(pw, { type: "reset", streamId: this.streamId });
580
- if (hadContent) this.emit(true); // clear-the-view notify is synchronous
419
+ this.pool.send(pw, { type: "reset", streamId: this.streamId, epoch: this.epoch });
420
+ if (hadContent) this.emit(true);
581
421
  }
582
-
583
422
  destroy() {
584
- if (!this.attached) return; // idempotent
585
- // Free this stream's parser; the shared worker stays warm for siblings.
586
- // Only release a real slot — a never-acquired client (constructed during an
587
- // SSR render then unmounted) has no pool slot to free, so skip the call.
588
- // We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
589
- // on the SAME instance needs the same pw/streamId to re-register.
423
+ if (!this.attached) return;
590
424
  if (this.pw) this.pool.release(this.streamId, this.pw);
591
- // Drop any pending coalesced frame so it can't fire into cleared listeners.
592
425
  this.cancelFrame();
593
426
  this.finalizePending = false;
594
427
  this.listeners.clear();
595
428
  this.attached = false;
596
429
  }
597
-
598
430
  /**
599
431
  * Re-register with the pool after {@link destroy} so the client receives
600
432
  * patches again. Needed only for React StrictMode's dev double-mount, where
@@ -603,61 +435,46 @@ export class FluxClient {
603
435
  */
604
436
  reattach() {
605
437
  if (this.attached) return;
606
- // The prior destroy()→dispose dropped this stream's parser, so setContent's
607
- // diff baseline is stale — clear it so the next setContent re-feeds the whole
608
- // document (StrictMode dev double-mount on the SAME instance).
609
438
  this.lastContent = "";
610
439
  this.contentDone = false;
611
440
  if (!this.pw) {
612
- // Never acquired (e.g. constructed during SSR, first real mount on client).
613
- // No prior pool slot to re-register; just mark attached. The next
614
- // worker-bound op acquires lazily. configSent is already false, so the
615
- // first append will carry config exactly as a brand-new client would.
616
441
  this.attached = true;
617
442
  return;
618
443
  }
619
444
  this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
620
445
  this.attached = true;
621
- // The worker discarded this stream's config on `dispose` (unlike `reset`,
622
- // which keeps it), so re-send it on the next message — otherwise the parser
623
- // would be rebuilt with library defaults (gfmMath / componentTags / … lost).
624
446
  this.configSent = false;
625
447
  }
626
-
627
- subscribe = (fn: () => void) => {
448
+ subscribe = (fn) => {
628
449
  this.listeners.add(fn);
629
450
  return () => this.listeners.delete(fn);
630
451
  };
631
-
632
- getSnapshot = (): Block[] => this.store.snapshot;
633
-
452
+ getSnapshot = () => this.store.snapshot;
634
453
  /**
635
454
  * Internal: a renderer with an `onRenderMetrics` hook calls this once per
636
455
  * actual React block render so `getMetrics().renderCount` aggregates churn.
637
456
  * No-op cost when no hook is wired (it is simply never called). Not part of
638
457
  * the public API surface — the underscore marks it renderer-internal.
639
458
  */
640
- __noteRender(): void {
459
+ __noteRender() {
641
460
  this.renderCount++;
642
461
  }
643
-
644
462
  /**
645
463
  * Internal: the DOM renderer calls this once per actual node rebuild (the
646
464
  * changed-block branch) when an `onRenderMetrics` hook is wired, so
647
465
  * `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
648
466
  */
649
- __noteRebuild(): void {
467
+ __noteRebuild() {
650
468
  this.rebuildCount++;
651
469
  }
652
-
653
470
  getMetrics() {
654
471
  const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
655
472
  return {
656
473
  bytes: this.appendedBytes,
657
474
  patches: this.patchCount,
658
475
  meanParseMicros: this.patchCount > 0 ? this.totalParseMicros / this.patchCount : 0,
659
- totalParseMs: this.totalParseMicros / 1000,
660
- throughputKBs: (this.appendedBytes / 1024) / (elapsed / 1000),
476
+ totalParseMs: this.totalParseMicros / 1e3,
477
+ throughputKBs: this.appendedBytes / 1024 / (elapsed / 1e3),
661
478
  committedBlocks: this.store.committed.size,
662
479
  activeBlocks: this.store.active.length,
663
480
  lastPatchAgoMs: this.lastPatchMs === 0 ? 0 : performance.now() - this.lastPatchMs,
@@ -670,31 +487,25 @@ export class FluxClient {
670
487
  // renderer): renderCount = React block-body renders, rebuildCount = DOM
671
488
  // node rebuilds. Committed blocks memo-skip, so they contribute once.
672
489
  renderCount: this.renderCount,
673
- rebuildCount: this.rebuildCount,
490
+ rebuildCount: this.rebuildCount
674
491
  };
675
492
  }
676
-
677
493
  /**
678
494
  * A heading outline of the current snapshot (committed + active), in document
679
495
  * order — for a table of contents. Works mid-stream; entries appear as their
680
496
  * headings stream in. The `id` is stable, so a built ToC won't re-key.
681
497
  */
682
- outline(): OutlineEntry[] {
683
- const out: OutlineEntry[] = [];
498
+ outline() {
499
+ const out = [];
684
500
  for (const b of this.store.snapshot) {
685
501
  if (b.kind.type === "Heading") {
686
- // `kind.data` is the bare level `number` when `blockData` is off, or the
687
- // `{ level, text, id }` object when on — accept both. `OutlineEntry.id`
688
- // stays the numeric block id (stable, non-breaking); the anchor slug is
689
- // reachable additively via `kind.data.id` for consumers who want it.
690
- const d = b.kind.data as number | { level?: number } | undefined;
502
+ const d = b.kind.data;
691
503
  const level = typeof d === "number" ? d : d?.level ?? 1;
692
504
  out.push({ level, text: htmlToText(b.html), id: b.id });
693
505
  }
694
506
  }
695
507
  return out;
696
508
  }
697
-
698
509
  /**
699
510
  * The rendered document as plain text — tags stripped, entities decoded,
700
511
  * blocks separated by blank lines. Derived from the rendered HTML (the source
@@ -702,48 +513,51 @@ export class FluxClient {
702
513
  * readable approximation for search indexing / summaries, not a round-trip of
703
514
  * the original source.
704
515
  */
705
- toPlaintext(): string {
706
- const parts: string[] = [];
516
+ toPlaintext() {
517
+ const parts = [];
707
518
  for (const b of this.store.snapshot) {
708
519
  const t = htmlToText(b.html);
709
520
  if (t) parts.push(t);
710
521
  }
711
522
  return parts.join("\n\n");
712
523
  }
713
-
714
- private onMessage(msg: FromWorker) {
524
+ onMessage(msg) {
715
525
  switch (msg.type) {
716
- case "patch":
717
- applyPatch(this.store, msg.patch);
526
+ case "patch": {
527
+ if (msg.epoch !== void 0 && msg.epoch < this.epoch) break;
528
+ let patch;
529
+ try {
530
+ patch = JSON.parse(msg.patch);
531
+ } catch (e) {
532
+ const message = e instanceof Error ? e.message : String(e);
533
+ if (this.onError) this.onError({ message: `flux: malformed patch (${message})` });
534
+ else console.error("flux: malformed patch:", message);
535
+ break;
536
+ }
537
+ applyPatch(this.store, patch);
718
538
  this.appendedBytes = msg.appendedBytes;
719
539
  this.totalParseMicros += msg.parseMicros;
720
540
  this.retainedBytes = msg.retainedBytes;
721
541
  this.wasmMemoryBytes = msg.wasmMemoryBytes;
722
542
  this.patchCount += 1;
723
543
  this.lastPatchMs = performance.now();
724
- // The post-finalize (done) patch must notify synchronously — never defer
725
- // stream completion to the next frame. One-shot: cleared after use.
726
- const sync = this.finalizePending;
544
+ const sync = this.finalizePending || msg.final === true;
727
545
  this.finalizePending = false;
728
546
  this.emit(sync);
729
- // After subscribers see the new snapshot, fire the per-block hook for
730
- // anything that just committed (document order). A throw here is
731
- // isolated by the pool's dispatch boundary and won't skip emit().
732
547
  if (this.onBlock) {
733
- for (const b of msg.patch.newly_committed) this.onBlock(b);
548
+ for (const b of patch.newly_committed) this.onBlock(b);
734
549
  }
735
550
  break;
551
+ }
736
552
  case "error":
737
553
  if (this.onError) {
738
554
  this.onError({ message: msg.message, fatal: msg.fatal });
739
555
  } else {
740
- // eslint-disable-next-line no-console
741
556
  console.error("flux worker error:", msg.message);
742
557
  }
743
558
  break;
744
559
  }
745
560
  }
746
-
747
561
  /**
748
562
  * Notify subscribers of a new snapshot.
749
563
  *
@@ -753,29 +567,33 @@ export class FluxClient {
753
567
  * into one notify. `sync` forces an immediate flush (stream completion / reset)
754
568
  * and cancels any frame already pending so the snapshot is delivered once.
755
569
  */
756
- private emit(sync = false) {
570
+ emit(sync = false) {
757
571
  if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
758
- if (this.rafHandle !== null) return; // a flush is already scheduled this frame
572
+ if (this.rafHandle !== null) return;
759
573
  this.rafHandle = requestAnimationFrame(() => {
760
574
  this.rafHandle = null;
761
575
  this.flushNow();
762
576
  });
763
577
  return;
764
578
  }
765
- // Synchronous path: drop any pending frame so its notify can't double-fire
766
- // after this one, then flush immediately.
767
579
  this.cancelFrame();
768
580
  this.flushNow();
769
581
  }
770
-
771
- private flushNow() {
582
+ flushNow() {
772
583
  for (const fn of this.listeners) fn();
773
584
  }
774
-
775
- private cancelFrame() {
585
+ cancelFrame() {
776
586
  if (this.rafHandle !== null) {
777
587
  if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
778
588
  this.rafHandle = null;
779
589
  }
780
590
  }
781
591
  }
592
+ export {
593
+ FluxClient,
594
+ FluxPool,
595
+ __resetDefaultPool,
596
+ applyPatch,
597
+ emptyBlockStore,
598
+ getDefaultPool
599
+ };