flux-md 0.16.2 → 0.17.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 (66) hide show
  1. package/README.md +70 -39
  2. package/dist/block-props.d.ts +18 -0
  3. package/dist/block-props.js +75 -0
  4. package/dist/client.d.ts +310 -0
  5. package/{src/client.ts → dist/client.js} +123 -319
  6. package/dist/dom.d.ts +110 -0
  7. package/dist/dom.js +576 -0
  8. package/dist/element.d.ts +20 -0
  9. package/dist/element.js +287 -0
  10. package/dist/hi.d.ts +12 -0
  11. package/{src/hi.ts → dist/hi.js} +42 -74
  12. package/dist/html-to-react.d.ts +40 -0
  13. package/dist/html-to-react.js +344 -0
  14. package/{src/index.ts → dist/index.d.ts} +5 -25
  15. package/dist/index.js +16 -0
  16. package/dist/morph.d.ts +28 -0
  17. package/dist/morph.js +166 -0
  18. package/dist/react.d.ts +204 -0
  19. package/dist/react.js +490 -0
  20. package/dist/renderers/CodeBlock.d.ts +7 -0
  21. package/dist/renderers/CodeBlock.js +75 -0
  22. package/dist/renderers/Math.d.ts +14 -0
  23. package/dist/renderers/Math.js +15 -0
  24. package/dist/renderers/Mermaid.d.ts +13 -0
  25. package/dist/renderers/Mermaid.js +15 -0
  26. package/dist/server.d.ts +61 -0
  27. package/dist/server.js +126 -0
  28. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  29. package/dist/solid.js +54 -0
  30. package/dist/svelte.d.ts +80 -0
  31. package/dist/svelte.js +59 -0
  32. package/dist/types-core.d.ts +377 -0
  33. package/dist/types-core.js +0 -0
  34. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  35. package/dist/types-react.js +0 -0
  36. package/dist/types.d.ts +2 -0
  37. package/dist/types.js +2 -0
  38. package/dist/vue.d.ts +94 -0
  39. package/dist/vue.js +79 -0
  40. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  41. package/{src → dist}/wasm/flux_md_core.js +50 -55
  42. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  43. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  44. package/dist/worker-core.d.ts +60 -0
  45. package/dist/worker-core.js +121 -0
  46. package/dist/worker.d.ts +1 -0
  47. package/dist/worker.js +48 -0
  48. package/package.json +22 -18
  49. package/src/block-props.ts +0 -141
  50. package/src/dom.ts +0 -946
  51. package/src/element.ts +0 -400
  52. package/src/html-to-react.ts +0 -455
  53. package/src/morph.ts +0 -253
  54. package/src/react.tsx +0 -1020
  55. package/src/renderers/CodeBlock.tsx +0 -116
  56. package/src/renderers/Math.tsx +0 -28
  57. package/src/renderers/Mermaid.tsx +0 -27
  58. package/src/server.tsx +0 -221
  59. package/src/svelte.ts +0 -179
  60. package/src/types-core.ts +0 -398
  61. package/src/types.ts +0 -7
  62. package/src/vue.ts +0 -184
  63. package/src/wasm/flux_md_core_bg.wasm +0 -0
  64. package/src/worker-core.ts +0 -174
  65. package/src/worker.ts +0 -72
  66. /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,7 +138,6 @@ 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);
@@ -239,83 +145,50 @@ export class FluxPool {
239
145
  }
240
146
  this.dispatch(msg.streamId, msg);
241
147
  }
242
-
243
148
  // Route a message to a stream's handler, isolating a throwing client callback
244
149
  // (e.g. a user-supplied onError) so it can neither break the worker message
245
150
  // loop nor starve sibling streams sharing this worker.
246
- private dispatch(streamId: number, msg: FromWorker): void {
151
+ dispatch(streamId, msg) {
247
152
  try {
248
153
  this.handlers.get(streamId)?.(msg);
249
154
  } catch (e) {
250
- // eslint-disable-next-line no-console
251
155
  console.error("flux: stream message handler threw", e);
252
156
  }
253
157
  }
254
158
  }
255
-
256
- function poolCap(): number {
159
+ function poolCap() {
257
160
  const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
258
161
  return Math.min(hc || 4, 8);
259
162
  }
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 {
163
+ let defaultPool = null;
164
+ function getDefaultPool() {
265
165
  if (!defaultPool) {
266
166
  defaultPool = new FluxPool(
267
- () => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike,
268
- poolCap(),
167
+ () => new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
168
+ poolCap()
269
169
  );
270
170
  }
271
171
  return defaultPool;
272
172
  }
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 {
173
+ function __resetDefaultPool() {
280
174
  defaultPool = null;
281
175
  }
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;
176
+ class FluxClient {
177
+ pool;
178
+ pw = null;
179
+ streamId = 0;
180
+ config;
181
+ configSent = false;
182
+ listeners = /* @__PURE__ */ new Set();
183
+ store = emptyBlockStore();
184
+ onError;
185
+ onBlock;
186
+ attached = true;
313
187
  // Diff baseline for setContent(): the full string fed in so far, and whether
314
188
  // it has been finalized. Cleared by reset()/reattach() (the worker drops the
315
189
  // parser there, so the baseline is stale and the document must be re-fed).
316
- private lastContent = "";
317
- private contentDone = false;
318
-
190
+ lastContent = "";
191
+ contentDone = false;
319
192
  // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
320
193
  // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
321
194
  // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
@@ -324,26 +197,24 @@ export class FluxClient {
324
197
  // reference-stable, so a dropped intermediate notify only skips a tail-only
325
198
  // render that the next flush supersedes. The finalize/done patch is exempt and
326
199
  // flushes synchronously, never deferred to the next frame.
327
- private coalesce = false;
328
- private rafHandle: number | null = null;
200
+ coalesce = false;
201
+ rafHandle = null;
329
202
  // Set by finalize(); the next patch's emit flushes synchronously (a 'done'
330
203
  // notification must not be deferred a frame) and clears it.
331
- private finalizePending = false;
332
-
204
+ finalizePending = false;
333
205
  // 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;
206
+ appendedBytes = 0;
207
+ patchCount = 0;
208
+ totalParseMicros = 0;
209
+ lastPatchMs = 0;
210
+ firstAppendMs = 0;
211
+ retainedBytes = 0;
212
+ wasmMemoryBytes = 0;
341
213
  // Render-path observability (advanced ONLY when an onRenderMetrics hook is
342
214
  // wired into a renderer; zero-cost otherwise). renderCount = React BlockView
343
215
  // body renders; rebuildCount = DOM node rebuilds.
344
- private renderCount = 0;
345
- private rebuildCount = 0;
346
-
216
+ renderCount = 0;
217
+ rebuildCount = 0;
347
218
  /**
348
219
  * @param options.pool worker pool to join (defaults to the shared
349
220
  * process-wide pool — pass a dedicated `FluxPool` only for isolation).
@@ -365,22 +236,13 @@ export class FluxClient {
365
236
  * pending frame is cancelled on `reset()`/`destroy()`. No effect when
366
237
  * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
367
238
  */
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
- ) {
239
+ constructor(options = {}) {
377
240
  this.pool = options.pool ?? getDefaultPool();
378
241
  this.config = options.config;
379
242
  this.onError = options.onError;
380
243
  this.onBlock = options.onBlock;
381
244
  this.coalesce = options.coalesce ?? false;
382
245
  }
383
-
384
246
  /**
385
247
  * Lazily reserve this client's stream id and bind it to a pool worker. The
386
248
  * SOLE place that calls pool.acquire() — so the worker is created on the FIRST
@@ -396,46 +258,38 @@ export class FluxClient {
396
258
  * necessarily owns the lowest streamId. This affects neither the pool cap nor
397
259
  * multiplexing (pick() is unchanged and remains the only path to create()).
398
260
  */
399
- private ensureAcquired(): PoolWorker {
261
+ ensureAcquired() {
400
262
  if (this.pw) return this.pw;
401
263
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
402
264
  this.streamId = streamId;
403
265
  this.pw = pw;
404
266
  return pw;
405
267
  }
406
-
407
- get ready(): boolean {
268
+ get ready() {
408
269
  return this.pw?.ready ?? false;
409
270
  }
410
-
411
- whenReady(): Promise<void> {
271
+ whenReady() {
412
272
  const pw = this.ensureAcquired();
413
273
  return this.pool.whenWorkerReady(pw);
414
274
  }
415
-
416
275
  // The config rides on the first message a stream sends; the worker applies it
417
276
  // when it creates the parser. postMessage is FIFO per worker, so it always
418
277
  // 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;
278
+ firstConfig() {
279
+ if (this.configSent || !this.config) return void 0;
421
280
  this.configSent = true;
422
281
  return this.config;
423
282
  }
424
-
425
- append(chunk: string) {
283
+ append(chunk) {
426
284
  const pw = this.ensureAcquired();
427
285
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
428
286
  this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
429
287
  }
430
-
431
288
  finalize() {
432
289
  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
290
  this.finalizePending = true;
436
291
  this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
437
292
  }
438
-
439
293
  /**
440
294
  * Pipe a source straight in: read it to completion, `append()` each chunk,
441
295
  * then `finalize()`. The LLM-native path — e.g.
@@ -453,60 +307,46 @@ export class FluxClient {
453
307
  * finalized (or cleanly on abort); rejects if the source itself errors.
454
308
  * Browser-only for byte sources (uses `TextDecoder`).
455
309
  */
456
- async pipeFrom(
457
- source: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
458
- opts?: { signal?: AbortSignal },
459
- ): Promise<void> {
310
+ async pipeFrom(source, opts) {
460
311
  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.
312
+ if (signal?.aborted) return;
466
313
  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
314
+ for await (const chunk of source) {
315
+ if (signal?.aborted) return;
469
316
  this.append(chunk);
470
317
  }
471
318
  if (!signal?.aborted) this.finalize();
472
319
  return;
473
320
  }
474
-
475
- // Byte source: a Response (use its body) or a ReadableStream directly.
476
321
  const body = "body" in source ? source.body : source;
477
322
  if (!body) {
478
- // An empty Response body (e.g. 204) is a completed, empty stream.
479
323
  this.finalize();
480
324
  return;
481
325
  }
482
326
  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
327
  const onAbort = () => {
487
- reader.cancel().catch(() => {});
328
+ reader.cancel().catch(() => {
329
+ });
488
330
  };
489
331
  signal?.addEventListener("abort", onAbort, { once: true });
490
332
  const decoder = new TextDecoder();
491
333
  try {
492
- for (;;) {
334
+ for (; ; ) {
493
335
  const { done, value } = await reader.read();
494
- if (signal?.aborted) return; // superseded: no finalize (cancel already fired)
336
+ if (signal?.aborted) return;
495
337
  if (done) break;
496
338
  if (value) this.append(decoder.decode(value, { stream: true }));
497
339
  }
498
- this.append(decoder.decode()); // flush any trailing partial sequence
340
+ this.append(decoder.decode());
499
341
  this.finalize();
500
342
  } finally {
501
343
  signal?.removeEventListener("abort", onAbort);
502
344
  try {
503
345
  reader.releaseLock();
504
346
  } catch {
505
- /* already released (e.g. by cancel) */
506
347
  }
507
348
  }
508
349
  }
509
-
510
350
  /**
511
351
  * Drive the parser from a CONTROLLED full string instead of manual appends.
512
352
  * Pass the whole document-so-far each time; setContent diffs it against the
@@ -532,17 +372,12 @@ export class FluxClient {
532
372
  * here (it forces a reparse each tick); do that enrichment at render time via
533
373
  * `components` instead, keeping the source append-only.
534
374
  */
535
- setContent(content: string, opts?: { done?: boolean }) {
375
+ setContent(content, opts) {
536
376
  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
377
  if (!this.contentDone && content.startsWith(this.lastContent)) {
543
378
  this.append(content.slice(this.lastContent.length));
544
379
  } else {
545
- this.reset(); // diverged, or reopening a finalized stream — rebuild
380
+ this.reset();
546
381
  this.append(content);
547
382
  }
548
383
  this.lastContent = content;
@@ -553,12 +388,7 @@ export class FluxClient {
553
388
  this.contentDone = true;
554
389
  }
555
390
  }
556
-
557
391
  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
392
  const hadContent = this.store.snapshot.length > 0;
563
393
  this.store = emptyBlockStore();
564
394
  this.appendedBytes = 0;
@@ -568,33 +398,22 @@ export class FluxClient {
568
398
  this.firstAppendMs = 0;
569
399
  this.retainedBytes = 0;
570
400
  this.wasmMemoryBytes = 0;
571
- this.lastContent = ""; // setContent baseline: the worker drops the parser here
401
+ this.lastContent = "";
572
402
  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
403
  this.cancelFrame();
576
404
  this.finalizePending = false;
577
- // Same streamId + worker — the worker frees and lazily recreates the parser.
578
405
  const pw = this.ensureAcquired();
579
406
  this.pool.send(pw, { type: "reset", streamId: this.streamId });
580
- if (hadContent) this.emit(true); // clear-the-view notify is synchronous
407
+ if (hadContent) this.emit(true);
581
408
  }
582
-
583
409
  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.
410
+ if (!this.attached) return;
590
411
  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
412
  this.cancelFrame();
593
413
  this.finalizePending = false;
594
414
  this.listeners.clear();
595
415
  this.attached = false;
596
416
  }
597
-
598
417
  /**
599
418
  * Re-register with the pool after {@link destroy} so the client receives
600
419
  * patches again. Needed only for React StrictMode's dev double-mount, where
@@ -603,61 +422,46 @@ export class FluxClient {
603
422
  */
604
423
  reattach() {
605
424
  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
425
  this.lastContent = "";
610
426
  this.contentDone = false;
611
427
  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
428
  this.attached = true;
617
429
  return;
618
430
  }
619
431
  this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
620
432
  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
433
  this.configSent = false;
625
434
  }
626
-
627
- subscribe = (fn: () => void) => {
435
+ subscribe = (fn) => {
628
436
  this.listeners.add(fn);
629
437
  return () => this.listeners.delete(fn);
630
438
  };
631
-
632
- getSnapshot = (): Block[] => this.store.snapshot;
633
-
439
+ getSnapshot = () => this.store.snapshot;
634
440
  /**
635
441
  * Internal: a renderer with an `onRenderMetrics` hook calls this once per
636
442
  * actual React block render so `getMetrics().renderCount` aggregates churn.
637
443
  * No-op cost when no hook is wired (it is simply never called). Not part of
638
444
  * the public API surface — the underscore marks it renderer-internal.
639
445
  */
640
- __noteRender(): void {
446
+ __noteRender() {
641
447
  this.renderCount++;
642
448
  }
643
-
644
449
  /**
645
450
  * Internal: the DOM renderer calls this once per actual node rebuild (the
646
451
  * changed-block branch) when an `onRenderMetrics` hook is wired, so
647
452
  * `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
648
453
  */
649
- __noteRebuild(): void {
454
+ __noteRebuild() {
650
455
  this.rebuildCount++;
651
456
  }
652
-
653
457
  getMetrics() {
654
458
  const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
655
459
  return {
656
460
  bytes: this.appendedBytes,
657
461
  patches: this.patchCount,
658
462
  meanParseMicros: this.patchCount > 0 ? this.totalParseMicros / this.patchCount : 0,
659
- totalParseMs: this.totalParseMicros / 1000,
660
- throughputKBs: (this.appendedBytes / 1024) / (elapsed / 1000),
463
+ totalParseMs: this.totalParseMicros / 1e3,
464
+ throughputKBs: this.appendedBytes / 1024 / (elapsed / 1e3),
661
465
  committedBlocks: this.store.committed.size,
662
466
  activeBlocks: this.store.active.length,
663
467
  lastPatchAgoMs: this.lastPatchMs === 0 ? 0 : performance.now() - this.lastPatchMs,
@@ -670,31 +474,25 @@ export class FluxClient {
670
474
  // renderer): renderCount = React block-body renders, rebuildCount = DOM
671
475
  // node rebuilds. Committed blocks memo-skip, so they contribute once.
672
476
  renderCount: this.renderCount,
673
- rebuildCount: this.rebuildCount,
477
+ rebuildCount: this.rebuildCount
674
478
  };
675
479
  }
676
-
677
480
  /**
678
481
  * A heading outline of the current snapshot (committed + active), in document
679
482
  * order — for a table of contents. Works mid-stream; entries appear as their
680
483
  * headings stream in. The `id` is stable, so a built ToC won't re-key.
681
484
  */
682
- outline(): OutlineEntry[] {
683
- const out: OutlineEntry[] = [];
485
+ outline() {
486
+ const out = [];
684
487
  for (const b of this.store.snapshot) {
685
488
  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;
489
+ const d = b.kind.data;
691
490
  const level = typeof d === "number" ? d : d?.level ?? 1;
692
491
  out.push({ level, text: htmlToText(b.html), id: b.id });
693
492
  }
694
493
  }
695
494
  return out;
696
495
  }
697
-
698
496
  /**
699
497
  * The rendered document as plain text — tags stripped, entities decoded,
700
498
  * blocks separated by blank lines. Derived from the rendered HTML (the source
@@ -702,48 +500,50 @@ export class FluxClient {
702
500
  * readable approximation for search indexing / summaries, not a round-trip of
703
501
  * the original source.
704
502
  */
705
- toPlaintext(): string {
706
- const parts: string[] = [];
503
+ toPlaintext() {
504
+ const parts = [];
707
505
  for (const b of this.store.snapshot) {
708
506
  const t = htmlToText(b.html);
709
507
  if (t) parts.push(t);
710
508
  }
711
509
  return parts.join("\n\n");
712
510
  }
713
-
714
- private onMessage(msg: FromWorker) {
511
+ onMessage(msg) {
715
512
  switch (msg.type) {
716
- case "patch":
717
- applyPatch(this.store, msg.patch);
513
+ case "patch": {
514
+ let patch;
515
+ try {
516
+ patch = JSON.parse(msg.patch);
517
+ } catch (e) {
518
+ const message = e instanceof Error ? e.message : String(e);
519
+ if (this.onError) this.onError({ message: `flux: malformed patch (${message})` });
520
+ else console.error("flux: malformed patch:", message);
521
+ break;
522
+ }
523
+ applyPatch(this.store, patch);
718
524
  this.appendedBytes = msg.appendedBytes;
719
525
  this.totalParseMicros += msg.parseMicros;
720
526
  this.retainedBytes = msg.retainedBytes;
721
527
  this.wasmMemoryBytes = msg.wasmMemoryBytes;
722
528
  this.patchCount += 1;
723
529
  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
530
  const sync = this.finalizePending;
727
531
  this.finalizePending = false;
728
532
  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
533
  if (this.onBlock) {
733
- for (const b of msg.patch.newly_committed) this.onBlock(b);
534
+ for (const b of patch.newly_committed) this.onBlock(b);
734
535
  }
735
536
  break;
537
+ }
736
538
  case "error":
737
539
  if (this.onError) {
738
540
  this.onError({ message: msg.message, fatal: msg.fatal });
739
541
  } else {
740
- // eslint-disable-next-line no-console
741
542
  console.error("flux worker error:", msg.message);
742
543
  }
743
544
  break;
744
545
  }
745
546
  }
746
-
747
547
  /**
748
548
  * Notify subscribers of a new snapshot.
749
549
  *
@@ -753,29 +553,33 @@ export class FluxClient {
753
553
  * into one notify. `sync` forces an immediate flush (stream completion / reset)
754
554
  * and cancels any frame already pending so the snapshot is delivered once.
755
555
  */
756
- private emit(sync = false) {
556
+ emit(sync = false) {
757
557
  if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
758
- if (this.rafHandle !== null) return; // a flush is already scheduled this frame
558
+ if (this.rafHandle !== null) return;
759
559
  this.rafHandle = requestAnimationFrame(() => {
760
560
  this.rafHandle = null;
761
561
  this.flushNow();
762
562
  });
763
563
  return;
764
564
  }
765
- // Synchronous path: drop any pending frame so its notify can't double-fire
766
- // after this one, then flush immediately.
767
565
  this.cancelFrame();
768
566
  this.flushNow();
769
567
  }
770
-
771
- private flushNow() {
568
+ flushNow() {
772
569
  for (const fn of this.listeners) fn();
773
570
  }
774
-
775
- private cancelFrame() {
571
+ cancelFrame() {
776
572
  if (this.rafHandle !== null) {
777
573
  if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
778
574
  this.rafHandle = null;
779
575
  }
780
576
  }
781
577
  }
578
+ export {
579
+ FluxClient,
580
+ FluxPool,
581
+ __resetDefaultPool,
582
+ applyPatch,
583
+ emptyBlockStore,
584
+ getDefaultPool
585
+ };