flux-md 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ Notable changes to flux-md. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.13.0 — 2026-06-04
8
+
9
+ ### Added
10
+
11
+ - **`FluxClient.setContent(content, { done })` + controlled-string helpers for
12
+ every binding** — a first-class bridge for UIs that hold a streaming message as
13
+ a single growing/controlled string prop (rather than a stream). setContent diffs
14
+ against the last value: a **prefix-extension** appends only the delta (committed
15
+ blocks stay put); any **divergence** (e.g. a finished message swapped for a
16
+ re-processed final string) resets and reparses. No hand-rolled diff, no
17
+ readiness gate. Pass `{ done: true }` / `streaming: false` to finalize. The
18
+ framework-neutral `setContent` is wrapped by an idiomatic, client-owning helper
19
+ per framework — React `useFluxMarkdownString`, Vue `useFluxMarkdownString`
20
+ (composable), Solid `createFluxMarkdownString`, Svelte `fluxMarkdownString`
21
+ (action) — each SSR-safe (feeds only in the client-only lifecycle hook). Vanilla
22
+ / `<flux-markdown>` use a caller-owned client + `setContent` directly.
23
+ - **`FluxPool.warm()`** — eagerly initialize one worker (`getDefaultPool().warm()`
24
+ on app load) so the one-time WASM init is off the first-token critical path; the
25
+ warm worker is the one the first stream attaches to, so the work isn't wasted.
26
+ - **Custom-component & `sanitize` overrides now apply to the OPEN (streaming)
27
+ block**, not just settled ones — a design-system renderer (Tailwind classes on
28
+ `p`/`ul`/`li`, inline `<a>`/`<code>` overrides) stays styled mid-stream instead
29
+ of only after a block commits. This also closes a gap where a supplied
30
+ `sanitize` previously bypassed component-rendered blocks; it now runs on every
31
+ block. The no-`components` path is unchanged (byte-identical `innerHTML`).
32
+
33
+ ### Fixed
34
+
35
+ - **Worker no longer drops the first chunk(s) under a slow WASM load.** The
36
+ worker buffered appends but did not gate parser creation on WASM readiness, so
37
+ an append that arrived before `init()` resolved would call `new FluxParser()`
38
+ against an uninitialized module — throwing `fluxparser_new of undefined` and
39
+ silently losing that chunk. Appends now accumulate (and `finalize` defers)
40
+ until init completes, then drain in order. Surfaced on a fresh Next.js /
41
+ Turbopack production load, where the worker+WASM fetch is slow enough to lose
42
+ the race; the fix is bundler-agnostic. The worker's message/readiness state
43
+ machine was extracted to `worker-core.ts` (dependency-injected, like
44
+ `FluxPool`'s worker factory) and now has a unit test (`worker-core.test.ts`)
45
+ covering the gate — buffer-until-ready, drain order, finalize/reset before
46
+ ready — so the regression can't silently return.
47
+ - **React 19 / Next.js type compatibility.** The shipped source used the global
48
+ `JSX.Element`, which React 19's `@types/react` removed — a consumer's
49
+ `next build` type-checks flux-md's source (it ships as `.tsx`) and failed with
50
+ *"Cannot find namespace 'JSX'"*. Now uses `ReactElement`, which type-checks
51
+ under `@types/react` 18 **and** 19.
52
+
53
+ ### Docs
54
+
55
+ - **Next.js (App Router) is now documented and verified** (Turbopack + webpack,
56
+ Next.js 16, `next dev` and `next build`): add flux-md to `transpilePackages`
57
+ and use it from a `"use client"` component. See the README's Next.js callout.
58
+
7
59
  ## 0.12.0 — 2026-05-30
8
60
 
9
61
  ### Added
package/README.md CHANGED
@@ -16,9 +16,9 @@ flux-md ships as **source** (TypeScript + the compiled WASM). The worker and
16
16
  WASM asset are referenced with the **web-standard `new URL(asset,
17
17
  import.meta.url)`** pattern, so any bundler with asset-module support resolves
18
18
  them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
19
- modules), and **Parcel**. Next.js (webpack/turbopack) should work but is
20
- untested file an issue if it doesn't. It is **browser-only** (it constructs
21
- Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
19
+ modules), **Parcel**, and **Next.js** (App Router Turbopack *and* webpack;
20
+ **verified on Next.js 16**, see the [Next.js callout](#nextjs) below). It is
21
+ **browser-only** (it constructs Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
22
22
  `vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
23
23
  need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
24
24
  `flux-md/dom`, `flux-md/element`) needs none.
@@ -37,6 +37,54 @@ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
37
37
  >
38
38
  > No other bundler needs this — it's specific to Vite's optimizer.
39
39
 
40
+ <a id="nextjs"></a>
41
+
42
+ > **Next.js (App Router) — two requirements.** Verified on **Next.js 16** with
43
+ > **Turbopack** (the default for both `next dev` and `next build`). The same two
44
+ > requirements apply under webpack. Because flux-md ships TypeScript source:
45
+ >
46
+ > 1. **Transpile the package.** Next does not compile `node_modules` TypeScript
47
+ > by default — without this, Turbopack errors with *"Unknown module type"* on
48
+ > `react.tsx`. Add flux-md to `transpilePackages`:
49
+ >
50
+ > ```ts
51
+ > // next.config.ts
52
+ > import type { NextConfig } from "next";
53
+ > const nextConfig: NextConfig = { transpilePackages: ["flux-md"] };
54
+ > export default nextConfig;
55
+ > ```
56
+ >
57
+ > 2. **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
58
+ > spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
59
+ > a Server Component. (It is still SSR-safe: on the server it renders an empty
60
+ > shell and only starts streaming after hydration, so there's no SSR crash —
61
+ > the constraint is hooks, not the worker.)
62
+ >
63
+ > ```tsx
64
+ > "use client";
65
+ > import { FluxMarkdown } from "flux-md/react";
66
+ >
67
+ > export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
68
+ > return <FluxMarkdown stream={stream} />;
69
+ > }
70
+ > ```
71
+ >
72
+ > **Create the `stream` in Client Component code, not in a Server Component.**
73
+ > A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
74
+ > can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
75
+ > throws *"Only plain objects can be passed to Client Components."* Pass a
76
+ > serializable prop (a URL, the chat messages) from the server and open the
77
+ > stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
78
+ > effect, or the `useFluxStream` hook (see [Quick start](#quick-start)).
79
+ >
80
+ > That's it — Turbopack bundles the worker and emits the `.wasm` to
81
+ > `_next/static/media` itself, so no extra asset/loader config is needed (and the
82
+ > Vite `optimizeDeps` workaround above does **not** apply). Both `next dev` and
83
+ > `next build && next start` are verified to spawn the worker, load the WASM, and
84
+ > stream markdown. _Dev tip:_ open the app on `localhost` — Next dev blocks
85
+ > cross-origin dev resources (HMR, chunks) from other hosts (e.g. `127.0.0.1`)
86
+ > unless you add them to `allowedDevOrigins` in `next.config`.
87
+
40
88
  ## Quick start
41
89
 
42
90
  ```ts
@@ -79,6 +127,38 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
79
127
  }
80
128
  ```
81
129
 
130
+ ### Already holding a growing string? — `useFluxMarkdownString`
131
+
132
+ Many apps keep the streaming message as a **single growing string prop** (it
133
+ re-renders with the full text-so-far each token), not as a stream. Feed that
134
+ string straight in — `useFluxMarkdownString` diffs it for you and forwards only
135
+ the delta, so you don't hand-roll an append/reset bridge:
136
+
137
+ ```tsx
138
+ import { FluxMarkdown, useFluxMarkdownString } from "flux-md/react";
139
+
140
+ export function ChatMessage({ text, streaming }: { text: string; streaming: boolean }) {
141
+ const client = useFluxMarkdownString(text, { streaming });
142
+ return <FluxMarkdown client={client} />;
143
+ }
144
+ ```
145
+
146
+ It handles the two shapes a controlled string takes: a **prefix-extension** (the
147
+ common token-by-token growth) appends only the new suffix; a **divergence** (e.g.
148
+ the finished text swapped for a re-processed final string — bolded numbers,
149
+ wrapped tickers) resets and reparses. Pass `streaming: false` once the content is
150
+ final so the last block commits (a finished code fence then highlights). The
151
+ framework-neutral primitive is **`client.setContent(fullString, { done })`** —
152
+ use it from any binding.
153
+
154
+ > **Transforming streamed content?** If the enrichment runs **live per token**
155
+ > (e.g. bold every number as it arrives), do it at **render time** via
156
+ > [`components`](#custom-components--overrides) — keep the markdown source
157
+ > append-only so parsing stays incremental. Re-transforming the *whole* string
158
+ > each token (so earlier bytes change) forces `setContent` to reparse every tick
159
+ > (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
160
+ > for the **once**-at-the-end reprocess swap, not per-token rewrites.
161
+
82
162
  <details>
83
163
  <summary>Full manual control (caller-owned client)</summary>
84
164
 
@@ -150,6 +230,12 @@ handle.destroy();
150
230
  client.destroy();
151
231
  ```
152
232
 
233
+ **Already holding a growing string?** There's no framework reactivity to wrap,
234
+ so just call **`client.setContent(fullString, { done })`** instead of the
235
+ `append` loop — it diffs internally (prefix → delta; divergence → reparse) and
236
+ finalizes on `done`. That's the same primitive the React/Vue/Svelte/Solid
237
+ controlled-string helpers wrap; in vanilla you call it directly.
238
+
153
239
  `mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
154
240
  Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
155
241
  (default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
@@ -208,6 +294,13 @@ defineFluxMarkdown(); // once at bootstrap
208
294
  export class Answer { url = "/api/post.md"; }
209
295
  ```
210
296
 
297
+ **Controlled growing string?** Assign a caller-owned client and drive it with
298
+ `setContent` — `el.client = myClient; myClient.setContent(fullString, { done })`
299
+ — the element subscribes and renders, you own the diffing. (The self-owned
300
+ `markdown` attribute is **one-shot** — it re-parses the whole document on each
301
+ change, so don't point it at a per-token-growing string; use a client +
302
+ `setContent` for that.)
303
+
211
304
  ### Vue 3 — `flux-md/vue`
212
305
 
213
306
  ```vue
@@ -230,6 +323,20 @@ Props: `client` (required), `components`, `sanitize`, `virtualize`,
230
323
  `stickToBottom`. There's also a `useFluxMarkdown` composable returning a
231
324
  `container` ref if you'd rather mount into your own element.
232
325
 
326
+ **Already holding a growing string?** `useFluxMarkdownString` owns a client and
327
+ diffs the string for you (the Vue analogue of the React hook — see
328
+ [Controlled strings](#already-holding-a-growing-string--usefluxmarkdownstring)):
329
+
330
+ ```vue
331
+ <script setup lang="ts">
332
+ import { FluxMarkdown, useFluxMarkdownString } from "flux-md/vue";
333
+ const props = defineProps<{ text: string; streaming: boolean }>();
334
+ // Pass getters so the composable tracks the live values; it owns + destroys the client.
335
+ const client = useFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
336
+ </script>
337
+ <template><FluxMarkdown :client="client" /></template>
338
+ ```
339
+
233
340
  ### Svelte (4 & 5) — `flux-md/svelte`
234
341
 
235
342
  A Svelte action — works in both v4 and v5, no `.svelte` build step:
@@ -248,6 +355,20 @@ A Svelte action — works in both v4 and v5, no `.svelte` build step:
248
355
  <div use:fluxMarkdown={{ client, stickToBottom: true }} />
249
356
  ```
250
357
 
358
+ **Growing string?** The `fluxMarkdownString` action owns a client and diffs the
359
+ string — `use:fluxMarkdownString={{ content, streaming }}` (it destroys its
360
+ client on `destroy`, so no manual cleanup):
361
+
362
+ ```svelte
363
+ <script lang="ts">
364
+ import { fluxMarkdownString } from "flux-md/svelte";
365
+ export let content: string; // the growing message
366
+ export let streaming: boolean; // false once complete → finalizes
367
+ </script>
368
+
369
+ <div use:fluxMarkdownString={{ content, streaming, stickToBottom: true }} />
370
+ ```
371
+
251
372
  ### Solid — `flux-md/solid`
252
373
 
253
374
  ```tsx
@@ -262,6 +383,19 @@ onCleanup(() => client.destroy());
262
383
  <FluxMarkdown client={client} stickToBottom />;
263
384
  ```
264
385
 
386
+ **Growing string?** `createFluxMarkdownString` owns a client and diffs the string
387
+ (the Solid analogue of the React hook), driving `setContent` from a
388
+ `createEffect` and destroying the client on cleanup:
389
+
390
+ ```tsx
391
+ import { FluxMarkdown, createFluxMarkdownString } from "flux-md/solid";
392
+
393
+ function Message(props: { text: string; streaming: boolean }) {
394
+ const client = createFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
395
+ return <FluxMarkdown client={client} />;
396
+ }
397
+ ```
398
+
265
399
  The Solid binding's mount/teardown logic is tested, but its JSX component shell
266
400
  has so far only been exercised through a real Solid (`vite-plugin-solid`) build
267
401
  in development, not in CI — treat it as the newest of the bindings and file an
@@ -324,6 +458,10 @@ class FluxClient {
324
458
  opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
325
459
  ): Promise<void>;
326
460
  finalize(): void; // mark stream complete
461
+ setContent( // drive from a controlled full string
462
+ full: string, // diffs vs last: prefix → append delta; else reset+reparse
463
+ opts?: { done?: boolean }, // done:true → finalize
464
+ ): void;
327
465
  reset(): void; // wipe and reuse
328
466
  destroy(): void; // free this stream's parser
329
467
  whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
@@ -502,11 +640,15 @@ Rules worth knowing:
502
640
  channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
503
641
  `block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
504
642
  `list` fields) directly — no HTML re-parsing.
505
- - **Open (streaming) blocks render via `innerHTML`** their HTML is still
506
- partial, so a tag-level override takes effect the moment the block commits.
643
+ - **Overrides apply to the OPEN (streaming) block too**, not just settled ones
644
+ so a design-system renderer (Tailwind classes on `p`/`ul`/`li`, inline
645
+ `<a>`/`<code>` overrides) stays styled mid-stream. The tail's HTML is always
646
+ well-formed (the parser speculatively closes it). If a `sanitize` is supplied
647
+ it runs first, on every block.
507
648
  - **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
508
- output). The HTML→React conversion only runs for closed blocks when you
509
- actually supply overrides, and is memoized per `(block id, html)`.
649
+ output). The HTML→React conversion runs only when you actually supply
650
+ overrides, and is memoized per `(block id, html)` so committed blocks don't
651
+ re-parse as the stream grows.
510
652
  - For **code blocks** the built-in highlighter is the default; it is bypassed
511
653
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
512
654
  or `components.code`.
@@ -736,6 +878,16 @@ teardown? Construct your own `new FluxPool(factory, cap)` and pass it to
736
878
  **per-page singleton** — don't rely on it in SSR/RSC. For isolation between
737
879
  independent feature areas, give each its own `new FluxPool()`.
738
880
 
881
+ **Warm the pool to hide WASM init.** The one-time WASM load happens on the first
882
+ worker-bound op, which lands on the first-token critical path. Call
883
+ `getDefaultPool().warm()` on app load / route entry to start it early — the warm
884
+ worker is the one the first stream attaches to, so the init isn't wasted:
885
+
886
+ ```ts
887
+ import { getDefaultPool } from "flux-md";
888
+ useEffect(() => { getDefaultPool().warm(); }, []); // (or your framework's mount hook)
889
+ ```
890
+
739
891
  ### Long documents — `virtualize`
740
892
 
741
893
  For very long documents (hundreds+ of blocks), pass `virtualize` to apply CSS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./src/worker.ts", "./src/styles.css"],
package/src/client.ts CHANGED
@@ -151,6 +151,21 @@ export class FluxPool {
151
151
  return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
152
152
  }
153
153
 
154
+ /**
155
+ * Eagerly spin up one worker so WASM init starts BEFORE the first stream —
156
+ * taking the one-time init off the first-token critical path (e.g. call
157
+ * `getDefaultPool().warm()` on app load / route entry). Reuses a live worker
158
+ * if one exists; the warm worker is the one the first stream attaches to (it
159
+ * has spare capacity), so the work is not wasted. Resolves when that worker has
160
+ * finished initializing WASM; rejects if init fails fatally. Browser-only (it
161
+ * constructs a `Worker`).
162
+ */
163
+ warm(): Promise<void> {
164
+ const live = this.workers.filter((w) => !w.failed);
165
+ const pw = live[0] ?? this.create();
166
+ return this.whenWorkerReady(pw);
167
+ }
168
+
154
169
  /** Terminate every worker (test teardown / full shutdown). */
155
170
  disposeAll(): void {
156
171
  for (const pw of this.workers) {
@@ -286,6 +301,11 @@ export class FluxClient {
286
301
  private onError?: (err: { message: string; fatal?: boolean }) => void;
287
302
  private onBlock?: (block: Block) => void;
288
303
  private attached = true;
304
+ // Diff baseline for setContent(): the full string fed in so far, and whether
305
+ // it has been finalized. Cleared by reset()/reattach() (the worker drops the
306
+ // parser there, so the baseline is stale and the document must be re-fed).
307
+ private lastContent = "";
308
+ private contentDone = false;
289
309
 
290
310
  // Perf
291
311
  private appendedBytes = 0;
@@ -446,6 +466,53 @@ export class FluxClient {
446
466
  }
447
467
  }
448
468
 
469
+ /**
470
+ * Drive the parser from a CONTROLLED full string instead of manual appends.
471
+ * Pass the whole document-so-far each time; setContent diffs it against the
472
+ * last value and does the minimal work:
473
+ * - **prefix-extension** (the streaming-growth case) → append only the new
474
+ * suffix, so committed blocks stay put and only the active tail re-parses;
475
+ * - **any other change** (e.g. a finished stream swapped for a re-processed
476
+ * final string) → `reset()` + reparse the whole new string.
477
+ *
478
+ * This is the first-class bridge for UIs that hold a streaming message as a
479
+ * single growing string prop (the common React shape) — no hand-rolled diff,
480
+ * no readiness gate (appends before WASM is ready are buffered). Pass
481
+ * `{ done: true }` once the content is final to `finalize()` (idempotent within
482
+ * a generation; a content change *after* done reopens the stream via a fresh
483
+ * reparse, since a finalized parser is terminal and can't be appended to).
484
+ * Drive a given client with `setContent` *or* manual `append()`/`finalize()`,
485
+ * not both — they share the internal diff baseline.
486
+ *
487
+ * v1 note: the non-prefix path is a full reparse, not a partial rewind —
488
+ * committed blocks are frozen, so there is no truncate-to-offset. For the
489
+ * common case (append-growth + one end-of-stream swap) that is optimal. A
490
+ * transform that rewrites *earlier* bytes on every update is an anti-pattern
491
+ * here (it forces a reparse each tick); do that enrichment at render time via
492
+ * `components` instead, keeping the source append-only.
493
+ */
494
+ setContent(content: string, opts?: { done?: boolean }) {
495
+ if (content !== this.lastContent) {
496
+ // Fast path appends the delta into the EXISTING parser — but a parser that
497
+ // was already finalized ({ done: true }) is terminal: the core drops any
498
+ // further append. So gate the fast path on !contentDone; reopening a
499
+ // finalized stream (or any divergence) falls through to reset()+reparse,
500
+ // which frees the dead parser and rebuilds a fresh one.
501
+ if (!this.contentDone && content.startsWith(this.lastContent)) {
502
+ this.append(content.slice(this.lastContent.length));
503
+ } else {
504
+ this.reset(); // diverged, or reopening a finalized stream — rebuild
505
+ this.append(content);
506
+ }
507
+ this.lastContent = content;
508
+ this.contentDone = false;
509
+ }
510
+ if (opts?.done && !this.contentDone) {
511
+ this.finalize();
512
+ this.contentDone = true;
513
+ }
514
+ }
515
+
449
516
  reset() {
450
517
  this.store = emptyBlockStore();
451
518
  this.appendedBytes = 0;
@@ -455,6 +522,8 @@ export class FluxClient {
455
522
  this.firstAppendMs = 0;
456
523
  this.retainedBytes = 0;
457
524
  this.wasmMemoryBytes = 0;
525
+ this.lastContent = ""; // setContent baseline: the worker drops the parser here
526
+ this.contentDone = false;
458
527
  // Same streamId + worker — the worker frees and lazily recreates the parser.
459
528
  const pw = this.ensureAcquired();
460
529
  this.pool.send(pw, { type: "reset", streamId: this.streamId });
@@ -481,6 +550,11 @@ export class FluxClient {
481
550
  */
482
551
  reattach() {
483
552
  if (this.attached) return;
553
+ // The prior destroy()→dispose dropped this stream's parser, so setContent's
554
+ // diff baseline is stale — clear it so the next setContent re-feeds the whole
555
+ // document (StrictMode dev double-mount on the SAME instance).
556
+ this.lastContent = "";
557
+ this.contentDone = false;
484
558
  if (!this.pw) {
485
559
  // Never acquired (e.g. constructed during SSR, first real mount on client).
486
560
  // No prior pool slot to re-register; just mark attached. The next
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * client.finalize();
17
17
  */
18
18
  export { FluxClient, FluxPool, getDefaultPool } from "./client";
19
- export { FluxMarkdown } from "./react";
19
+ export { FluxMarkdown, useFluxStream, useFluxMarkdownString } from "./react";
20
20
  export { highlight, supportedLangs } from "./hi";
21
21
  export { htmlToReact, parseTrustedHtml } from "./html-to-react";
22
22
  export type {
package/src/react.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  useState,
8
8
  useSyncExternalStore,
9
9
  type CSSProperties,
10
+ type ReactElement,
10
11
  } from "react";
11
12
  import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
12
13
  import { FluxClient } from "./client";
@@ -209,6 +210,52 @@ export function useFluxStream(
209
210
  return client;
210
211
  }
211
212
 
213
+ /**
214
+ * Own a {@link FluxClient} driven by a CONTROLLED full string — the bridge for
215
+ * UIs that hold a streaming message as a single growing string prop (the common
216
+ * React shape) rather than as a stream. Pass the whole document-so-far on each
217
+ * render and {@link FluxClient.setContent} diffs it: a prefix-extension appends
218
+ * only the delta; any divergence (e.g. the finished text swapped for a
219
+ * re-processed final string) resets and reparses. Returns the owned client —
220
+ * pass it to `<FluxMarkdown client={…} />` (and read `outline()` etc.).
221
+ *
222
+ * Pass `streaming: false` once the content is final to finalize the stream and
223
+ * commit its last block (only then does a finished code fence highlight + show
224
+ * its copy button). If `streaming` is omitted or `true` the stream is left OPEN
225
+ * — right for a still-growing string, but a *complete static* string rendered as
226
+ * `useFluxMarkdownString(md)` keeps its last block in the streaming state until
227
+ * you pass `{ streaming: false }`. (Inferring "done" from an absent flag is
228
+ * deliberately avoided: it would re-finalize on every token for callers that
229
+ * grow the string without the flag — an O(n²) reparse trap.) The client is
230
+ * created once and destroyed on unmount; StrictMode's dev double-mount is handled
231
+ * (reattach re-feeds the document). For a true stream source
232
+ * (`Response` / `ReadableStream` / SSE generator) use {@link useFluxStream}
233
+ * instead — it avoids buffering the whole document as a string.
234
+ */
235
+ export function useFluxMarkdownString(
236
+ content: string,
237
+ options?: { config?: ParserConfig; streaming?: boolean },
238
+ ): FluxClient {
239
+ const [client] = useState(() => new FluxClient({ config: options?.config }));
240
+
241
+ // Own the client's pool attachment (StrictMode dev double-mount destroys on the
242
+ // simulated unmount then remounts the SAME instance; reattach re-registers and
243
+ // clears setContent's diff baseline so the document is re-fed). Destroy on the
244
+ // real unmount.
245
+ useEffect(() => {
246
+ client.reattach();
247
+ return () => client.destroy();
248
+ }, [client]);
249
+
250
+ // Reconcile the parser to the controlled string. setContent diffs internally,
251
+ // so this stays correct whether `content` grows by a token or is swapped wholesale.
252
+ useEffect(() => {
253
+ client.setContent(content, { done: options?.streaming === false });
254
+ }, [client, content, options?.streaming]);
255
+
256
+ return client;
257
+ }
258
+
212
259
  // Stream mode: own a client via the hook, then render the normal client path.
213
260
  function FluxMarkdownFromStream(props: FluxMarkdownProps) {
214
261
  const client = useFluxStream(props.stream, {
@@ -336,7 +383,10 @@ function componentInnerHtml(html: string, tag: string): string {
336
383
 
337
384
  /** Convert a closed block's HTML to a React tree, memoized on html+components. */
338
385
  function SafeHtml({ html, components }: { html: string; components: Components }) {
339
- return useMemo(() => htmlToReact(html, components), [html, components]) as JSX.Element;
386
+ // `ReactElement` (not the global `JSX.Element`) so the source type-checks under
387
+ // both @types/react 18 and 19 — React 19 removed the global `JSX` namespace,
388
+ // and a consumer's `next build` type-checks this shipped source.
389
+ return useMemo(() => htmlToReact(html, components), [html, components]) as ReactElement;
340
390
  }
341
391
 
342
392
  // Per-kind off-screen size estimate for `contain-intrinsic-size` — keeps the
@@ -419,12 +469,20 @@ function renderBlockContent({
419
469
  (block.open ? " flux-open" : "") +
420
470
  (block.speculative ? " flux-speculative" : "");
421
471
 
422
- // Tag-level overrides only apply to a settled block (open/speculative blocks
423
- // have partial HTML we must not feed to the parser).
424
- if (components && !block.open && !block.speculative) {
472
+ // Tag-level / inline overrides apply to OPEN and speculative blocks too, not
473
+ // just settled ones: the streaming tail's HTML is always well-formed (the
474
+ // parser speculatively closes it), so a design-system renderer (Tailwind
475
+ // classes on p/ul/li, inline <a>/<code> overrides) stays styled mid-stream
476
+ // instead of only after a block commits. A supplied `sanitize` runs FIRST
477
+ // (same as the innerHTML path below), so overrides compose with sanitization on
478
+ // every block — closing the gap where a component-rendered block previously
479
+ // bypassed the user sanitizer. The no-`components` fast path is untouched
480
+ // (byte-identical innerHTML).
481
+ if (components) {
482
+ const safe = sanitize ? sanitize(block.html) : block.html;
425
483
  return (
426
484
  <div className={className}>
427
- <SafeHtml html={block.html} components={components} />
485
+ <SafeHtml html={safe} components={components} />
428
486
  </div>
429
487
  );
430
488
  }
package/src/solid.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import { onCleanup, onMount, type JSX } from "solid-js";
2
- import type { FluxClient } from "./client";
1
+ import { createEffect, onCleanup, onMount, type JSX } from "solid-js";
2
+ import { FluxClient } from "./client";
3
+ import type { ParserConfig } from "./types-core";
3
4
  import { mountFluxMarkdown, type MountHandle, type MountOptions } from "./dom";
4
5
 
5
6
  /**
@@ -73,3 +74,72 @@ export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
73
74
  onMount(() => mountSolid(() => props, container, onCleanup));
74
75
  return container;
75
76
  }
77
+
78
+ /**
79
+ * Wire a controlled string to a freshly-constructed {@link FluxClient}, free of
80
+ * Solid's reactive runtime so it runs (and is tested) under any toolchain. The
81
+ * registrars are injected: the public {@link createFluxMarkdownString} passes
82
+ * Solid's real `createEffect` / `onCleanup`; tests pass hand-rolled stand-ins
83
+ * (mirroring how {@link mountSolid} takes `registerCleanup`).
84
+ *
85
+ * Ownership DIFFERS from {@link mountSolid}: this constructs the client and so
86
+ * `registerCleanup`s `client.destroy()` — it OWNS the worker/stream. `config` is
87
+ * read ONCE here (the constructor treats it as immutable); `getContent()` and
88
+ * `streaming` are read INSIDE the effect so the effect tracks them reactively.
89
+ */
90
+ export function setupFluxMarkdownString(
91
+ getContent: () => string,
92
+ getOptions: (() => { config?: ParserConfig; streaming?: boolean }) | undefined,
93
+ registerEffect: (fn: () => void) => void,
94
+ registerCleanup: (fn: () => void) => void,
95
+ ): FluxClient {
96
+ // One client per helper instance. Constructor is worker-free → SSR-safe; the
97
+ // worker is spawned lazily by the first setContent → append, which only runs
98
+ // inside the effect below. config is read once and is immutable thereafter.
99
+ const client = new FluxClient({ config: getOptions?.()?.config });
100
+
101
+ // Reconcile the parser to the controlled string. setContent diffs internally,
102
+ // so this is correct whether `content` grows by a token or is swapped wholesale.
103
+ // `streaming === false` (never `!streaming`) → only an explicit false finalizes;
104
+ // an absent/true flag leaves the stream open (inferring "done" from an absent
105
+ // flag would re-finalize on every token — an O(n²) reparse trap).
106
+ registerEffect(() => {
107
+ client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
108
+ });
109
+
110
+ // This helper OWNS the client (unlike the client-based bindings above), so it
111
+ // destroys it on cleanup — freeing its pool slot.
112
+ registerCleanup(() => client.destroy());
113
+
114
+ return client;
115
+ }
116
+
117
+ /**
118
+ * Own a {@link FluxClient} driven by a CONTROLLED full string — the Solid
119
+ * analogue of React's `useFluxMarkdownString`, for UIs that hold a streaming
120
+ * message as a single growing string (a signal/memo) rather than as a stream.
121
+ * Pass an accessor for the whole document-so-far; on every change
122
+ * {@link FluxClient.setContent} diffs it and does the minimal work (a
123
+ * prefix-extension appends only the delta; any divergence resets and reparses).
124
+ *
125
+ * Pass `streaming: false` (via `getOptions`) once the content is final to
126
+ * finalize the stream and commit its last block (only then does a finished code
127
+ * fence highlight + show its copy button). If `streaming` is omitted or `true`
128
+ * the stream is left OPEN. `config` is read once at construction and is
129
+ * immutable, so it is not a change trigger.
130
+ *
131
+ * **Returns the owned client** — pass it to `<FluxMarkdown client={client} />`
132
+ * (and read `outline()` / `getMetrics()` off it). The client is constructed in
133
+ * the body (constructor is worker-free → SSR-safe) and destroyed on cleanup.
134
+ *
135
+ * SSR-safety: `setContent` is what spawns a Worker (via `append`), so it runs
136
+ * ONLY inside a `createEffect` — Solid does not run user effects during
137
+ * `renderToString`, so nothing touches a Worker on the server render path (the
138
+ * body only constructs the worker-free client).
139
+ */
140
+ export function createFluxMarkdownString(
141
+ getContent: () => string,
142
+ getOptions?: () => { config?: ParserConfig; streaming?: boolean },
143
+ ): FluxClient {
144
+ return setupFluxMarkdownString(getContent, getOptions, createEffect, onCleanup);
145
+ }
package/src/svelte.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ActionReturn } from "svelte/action";
2
- import type { FluxClient } from "./client";
2
+ import { FluxClient } from "./client";
3
+ import type { ParserConfig } from "./types-core";
3
4
  import { mountFluxMarkdown, type DomComponents, type MountOptions } from "./dom";
4
5
 
5
6
  /**
@@ -53,3 +54,101 @@ export function fluxMarkdown(
53
54
  },
54
55
  };
55
56
  }
57
+
58
+ /**
59
+ * Controlled-string sibling of {@link fluxMarkdown}: instead of taking a
60
+ * caller-owned client, this action OWNS a single {@link FluxClient} (constructed
61
+ * from `config`) and drives it from a CONTROLLED full string — the bridge for
62
+ * Svelte UIs that hold a streaming message as one growing `content` prop rather
63
+ * than feeding the client by hand. Each update passes the whole document-so-far
64
+ * and {@link FluxClient.setContent} diffs it: a prefix-extension appends only the
65
+ * delta; any divergence resets and reparses.
66
+ *
67
+ * ```svelte
68
+ * <div use:fluxMarkdownString={{ content, streaming: !done }} />
69
+ * ```
70
+ *
71
+ * Pass `streaming: false` once the content is final to finalize the stream and
72
+ * commit its last block (only then does a finished code fence highlight + show
73
+ * its copy button). When `streaming` is omitted or `true` the stream is left
74
+ * OPEN — right for a still-growing string, but a *complete static* string keeps
75
+ * its last block in the streaming state until you pass `{ streaming: false }`.
76
+ * (Inferring "done" from an absent flag is deliberately avoided — it would
77
+ * re-finalize on every token and trip an O(n²) reparse.)
78
+ *
79
+ * SSR-safe by construction: a Svelte action runs ONLY in the browser, and the
80
+ * `FluxClient` constructor is worker-free — the first worker is spawned lazily by
81
+ * `setContent`, which only runs here (never during a server render).
82
+ *
83
+ * Lifecycle differs from {@link fluxMarkdown}: this action constructs the client
84
+ * once (a later `config` change is ignored, like a created-once instance) and
85
+ * `destroy()`s it on teardown — it OWNS the client. The mount-option reconcile
86
+ * (`components`/`sanitize`/`virtualize`/`stickToBottom`) matches `fluxMarkdown`,
87
+ * but the remount reuses the SAME client so its `setContent` diff baseline
88
+ * survives.
89
+ */
90
+ export interface FluxMarkdownStringParams extends Omit<FluxMarkdownParams, "client"> {
91
+ /** The full document-so-far. Diffed against the prior value on every update. */
92
+ content: string;
93
+ /** Leave the stream open while true/omitted; `false` finalizes (commits the tail). */
94
+ streaming?: boolean;
95
+ /** Per-stream parser flags. Applied once at construction; later changes are ignored. */
96
+ config?: ParserConfig;
97
+ }
98
+
99
+ /** Strip the action-only inputs (`content`/`streaming`/`config`), leaving the
100
+ * fields {@link mountFluxMarkdown} reads — so they never leak into the mount. */
101
+ function mountOptionsOf(p: FluxMarkdownStringParams): Omit<FluxMarkdownParams, "client"> {
102
+ const { content: _c, streaming: _s, config: _cfg, ...rest } = p;
103
+ void _c;
104
+ void _s;
105
+ void _cfg;
106
+ return rest;
107
+ }
108
+
109
+ export function fluxMarkdownString(
110
+ node: HTMLElement,
111
+ params: FluxMarkdownStringParams,
112
+ ): ActionReturn<FluxMarkdownStringParams> {
113
+ // This action OWNS the client — construct it once from `config` (a later
114
+ // `config` change is ignored, mirroring the created-once React hook). The
115
+ // content/streaming diff baseline lives INSIDE the client (setContent), so we
116
+ // keep no outer copy; only the mount-option fields are tracked for the remount
117
+ // comparison.
118
+ let options = mountOptionsOf(params);
119
+ const client = new FluxClient({ config: params.config });
120
+ let handle = mountFluxMarkdown(client, node, options as MountOptions);
121
+ // First worker-bound op: spawns the lazy Worker — browser-only, never SSR.
122
+ client.setContent(params.content, { done: params.streaming === false });
123
+
124
+ return {
125
+ update(next: FluxMarkdownStringParams) {
126
+ // Content/streaming are the primary changing inputs, so reconcile them on
127
+ // EVERY update — setContent self-no-ops when the string is unchanged, so
128
+ // this is cheap. (Unlike fluxMarkdown, we cannot early-return: that would
129
+ // swallow content updates.)
130
+ client.setContent(next.content, { done: next.streaming === false });
131
+
132
+ // Then reconcile mount options exactly like fluxMarkdown: remount only when
133
+ // a field the renderer reads actually changed identity, and reuse the SAME
134
+ // client so its setContent diff baseline (lastContent) survives the remount.
135
+ if (
136
+ next.components === options.components &&
137
+ next.sanitize === options.sanitize &&
138
+ next.virtualize === options.virtualize &&
139
+ next.stickToBottom === options.stickToBottom
140
+ ) {
141
+ return;
142
+ }
143
+ handle.destroy();
144
+ options = mountOptionsOf(next);
145
+ handle = mountFluxMarkdown(client, node, options as MountOptions);
146
+ },
147
+ destroy() {
148
+ // This action OWNS the client (unlike fluxMarkdown) — tear down the mount
149
+ // AND destroy the client so its pool slot is freed.
150
+ handle.destroy();
151
+ client.destroy();
152
+ },
153
+ };
154
+ }
package/src/vue.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineComponent, h, onMounted, onUnmounted, ref, watch } from "vue";
2
2
  import type { PropType, Ref } from "vue";
3
- import type { FluxClient } from "./client";
3
+ import { FluxClient } from "./client";
4
+ import type { ParserConfig } from "./types-core";
4
5
  import { mountFluxMarkdown, type DomComponents, type MountHandle, type MountOptions } from "./dom";
5
6
 
6
7
  /**
@@ -98,3 +99,59 @@ export const FluxMarkdown = defineComponent({
98
99
  return () => h("div", { ref: container });
99
100
  },
100
101
  });
102
+
103
+ /**
104
+ * Own a {@link FluxClient} driven by a CONTROLLED full string — the Vue analogue
105
+ * of React's `useFluxMarkdownString`, for UIs that hold a streaming message as a
106
+ * single growing string (a `ref`/computed) rather than as a stream. Pass a getter
107
+ * for the whole document-so-far; on every change {@link FluxClient.setContent}
108
+ * diffs it and does the minimal work (prefix-extension appends only the delta;
109
+ * any divergence resets and reparses).
110
+ *
111
+ * Pass `streaming: false` (via `getOptions`) once the content is final to
112
+ * finalize the stream and commit its last block. If `streaming` is omitted or
113
+ * `true` the stream is left OPEN — inferring "done" from an absent flag is
114
+ * deliberately avoided (it would re-finalize on every token for callers that
115
+ * grow the string without the flag — an O(n²) reparse trap). `config` is read
116
+ * once at construction and is immutable thereafter, so it is not a change
117
+ * trigger.
118
+ *
119
+ * **Returns the owned client** — a deliberate divergence from {@link useFluxMarkdown}
120
+ * (which returns `{ container }`). Mirroring React's hook, this composes with the
121
+ * component as `<FluxMarkdown :client="client" />` (and lets you read
122
+ * `outline()` / `getMetrics()` off it). The client is created in the composable
123
+ * body (constructor is worker-free → SSR-safe) and destroyed on unmount.
124
+ *
125
+ * SSR-safety: `setContent` is what spawns a Worker (via `append`), so it is
126
+ * called ONLY in `onMounted` and a NON-immediate `watch` — never during the
127
+ * server render path (`setup` constructs the client but neither lifecycle hook
128
+ * nor the non-immediate watch fires on the server).
129
+ */
130
+ export function useFluxMarkdownString(
131
+ getContent: () => string,
132
+ getOptions?: () => { config?: ParserConfig; streaming?: boolean },
133
+ ): FluxClient {
134
+ // One client per composable instance. Constructor is worker-free, so this is
135
+ // safe to run in setup() during SSR; config is read once and is immutable.
136
+ const client = new FluxClient({ config: getOptions?.()?.config });
137
+
138
+ // Reconcile the parser to the controlled string. setContent diffs internally,
139
+ // so this is correct whether `content` grows by a token or is swapped wholesale.
140
+ // `streaming === false` (never `!streaming`) → only an explicit false finalizes;
141
+ // an absent/true flag leaves the stream open.
142
+ const apply = (): void => {
143
+ client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
144
+ };
145
+
146
+ // Initial feed + every change. NOT { immediate: true }: an immediate watch runs
147
+ // in setup() — i.e. during SSR — and would spawn a Worker on the server. The
148
+ // initial feed is onMounted (client-only); the watch covers later changes.
149
+ onMounted(apply);
150
+ watch([getContent, () => getOptions?.()?.streaming], apply);
151
+
152
+ // This composable OWNS the client (unlike useFluxMarkdown, which takes one), so
153
+ // it destroys it here. Vue auto-stops the watcher on unmount.
154
+ onUnmounted(() => client.destroy());
155
+
156
+ return client;
157
+ }
Binary file
@@ -2,7 +2,7 @@
2
2
  "name": "flux-md-core",
3
3
  "type": "module",
4
4
  "description": "Incremental, streaming-aware markdown parser with speculative closure",
5
- "version": "0.12.0",
5
+ "version": "0.13.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",
@@ -0,0 +1,174 @@
1
+ import type { FromWorker, ParserConfig, Patch, ToWorker } from "./types-core";
2
+
3
+ /** The slice of `FluxParser` the worker drives — narrowed to an interface so the
4
+ * message/readiness state machine is unit-testable with a fake parser, no WASM.
5
+ * (Same testability move as {@link FluxPool} taking an injected worker factory.) */
6
+ export interface ParserLike {
7
+ append(chunk: string): Patch;
8
+ finalize(): Patch;
9
+ free(): void;
10
+ retainedBytes(): number;
11
+ }
12
+
13
+ /** Dependencies injected into {@link WorkerCore}, isolating it from the worker
14
+ * globals (`self`, `queueMicrotask`) and the WASM module so it can be tested. */
15
+ export interface WorkerCoreDeps {
16
+ /** Create + configure a parser for a stream (prod: `new FluxParser()` + setX). */
17
+ makeParser(config: ParserConfig | undefined): ParserLike;
18
+ /** Post a message to the main thread (prod: `self.postMessage`). */
19
+ post(msg: FromWorker): void;
20
+ /** Current WASM heap size in bytes, reported on each patch (0 if unknown). */
21
+ memBytes(): number;
22
+ /** Defer a flush to a future microtask (prod: `queueMicrotask`). */
23
+ schedule(fn: () => void): void;
24
+ }
25
+
26
+ /**
27
+ * The worker's message reducer + WASM-readiness gate, extracted from the worker
28
+ * shell so its trickiest invariant is testable without a real Worker or WASM.
29
+ *
30
+ * **The invariant:** WASM `init()` is async, and the client does NOT wait for
31
+ * readiness before appending — so chunks can arrive first. A parser must never
32
+ * be constructed before init resolves (`new FluxParser()` against an
33
+ * uninitialized module throws `fluxparser_new of undefined` and silently drops
34
+ * that chunk). So while `ready` is false, appends only accumulate in `pending`
35
+ * (scheduleFlush is a no-op) and `finalize` is deferred; {@link markReady}
36
+ * drains both — appends first (creating each parser + applying buffered text),
37
+ * then any deferred finalize — once init has completed.
38
+ */
39
+ export class WorkerCore {
40
+ // One parser per stream id; WASM is loaded once and shared by all of them.
41
+ private parsers = new Map<number, ParserLike>();
42
+ private config = new Map<number, ParserConfig>();
43
+ private pending = new Map<number, string>();
44
+ private totalAppended = new Map<number, number>();
45
+ private finalizePending = new Set<number>();
46
+ private flushScheduled = false;
47
+ private ready = false;
48
+
49
+ constructor(private deps: WorkerCoreDeps) {}
50
+
51
+ /** Handle one message from the main thread (append/finalize/reset/dispose). */
52
+ handle(msg: ToWorker): void {
53
+ const id = msg.streamId;
54
+ // Stash any per-stream config carried on the first message (FIFO guarantees
55
+ // it arrives before the parser is created in flush/finalize).
56
+ if ((msg.type === "append" || msg.type === "finalize") && msg.config) {
57
+ this.config.set(id, msg.config);
58
+ }
59
+ switch (msg.type) {
60
+ case "append":
61
+ this.pending.set(id, (this.pending.get(id) ?? "") + msg.chunk);
62
+ this.scheduleFlush();
63
+ break;
64
+ case "finalize":
65
+ // Before WASM is ready, defer: markReady() finalizes it after init (the
66
+ // buffered input is drained first). Otherwise finalize now.
67
+ if (!this.ready) this.finalizePending.add(id);
68
+ else this.doFinalize(id);
69
+ break;
70
+ case "reset":
71
+ // Free and recreate lazily on the next append — same stream id, **same
72
+ // config** (kept). The client resets its local state synchronously.
73
+ this.parsers.get(id)?.free();
74
+ this.parsers.delete(id);
75
+ this.pending.delete(id);
76
+ this.finalizePending.delete(id); // a reset cancels a not-yet-run early finalize
77
+ this.totalAppended.delete(id);
78
+ break;
79
+ case "dispose":
80
+ this.dispose(id);
81
+ break;
82
+ }
83
+ }
84
+
85
+ /** Called once WASM init resolves: open the gate and drain what was buffered. */
86
+ markReady(): void {
87
+ this.ready = true;
88
+ this.deps.post({ type: "ready" });
89
+ // Order matters: flush appends first (creating each parser + applying
90
+ // buffered text), then finalize any stream that already requested it.
91
+ if (this.pending.size > 0) this.flush();
92
+ if (this.finalizePending.size > 0) {
93
+ for (const id of this.finalizePending) this.doFinalize(id);
94
+ this.finalizePending.clear();
95
+ }
96
+ }
97
+
98
+ private getOrCreate(streamId: number): ParserLike {
99
+ let p = this.parsers.get(streamId);
100
+ if (!p) {
101
+ p = this.deps.makeParser(this.config.get(streamId));
102
+ this.parsers.set(streamId, p);
103
+ }
104
+ return p;
105
+ }
106
+
107
+ private dispose(streamId: number): void {
108
+ this.parsers.get(streamId)?.free();
109
+ this.parsers.delete(streamId);
110
+ this.config.delete(streamId);
111
+ this.pending.delete(streamId);
112
+ this.finalizePending.delete(streamId);
113
+ this.totalAppended.delete(streamId);
114
+ }
115
+
116
+ private emitPatch(streamId: number, patch: Patch, parser: ParserLike, parseMicros: number): void {
117
+ this.deps.post({
118
+ type: "patch",
119
+ streamId,
120
+ patch,
121
+ appendedBytes: this.totalAppended.get(streamId) ?? 0,
122
+ parseMicros,
123
+ retainedBytes: parser.retainedBytes(),
124
+ wasmMemoryBytes: this.deps.memBytes(),
125
+ });
126
+ }
127
+
128
+ private scheduleFlush(): void {
129
+ if (this.flushScheduled || !this.ready) return; // before ready, input just accumulates in `pending`
130
+ this.flushScheduled = true;
131
+ this.deps.schedule(() => this.flush());
132
+ }
133
+
134
+ private flush(): void {
135
+ this.flushScheduled = false;
136
+ if (!this.ready || this.pending.size === 0) return; // buffer stays put until WASM is ready
137
+ // Process every stream with buffered input this microtask.
138
+ for (const [streamId, chunk] of this.pending) {
139
+ this.pending.delete(streamId);
140
+ if (chunk.length === 0) continue;
141
+ const t0 = performance.now();
142
+ try {
143
+ // getOrCreate (→ makeParser) is inside the try: with `ready` gating it
144
+ // can't hit the init race, but any other construction failure becomes a
145
+ // posted error rather than an uncaught exception that kills the worker.
146
+ const parser = this.getOrCreate(streamId);
147
+ const patch = parser.append(chunk) as Patch;
148
+ const dt = (performance.now() - t0) * 1000;
149
+ this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + chunk.length);
150
+ this.emitPatch(streamId, patch, parser, dt);
151
+ } catch (e: unknown) {
152
+ this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
153
+ }
154
+ }
155
+ }
156
+
157
+ // Drain a stream's buffered input (if any), then finalize its parser. Shared by
158
+ // the `finalize` message path and markReady()'s post-ready drain.
159
+ private doFinalize(streamId: number): void {
160
+ const buffered = this.pending.get(streamId);
161
+ this.pending.delete(streamId);
162
+ try {
163
+ const parser = this.getOrCreate(streamId);
164
+ if (buffered && buffered.length > 0) {
165
+ parser.append(buffered);
166
+ this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + buffered.length);
167
+ }
168
+ const patch = parser.finalize() as Patch;
169
+ this.emitPatch(streamId, patch, parser, 0);
170
+ } catch (e: unknown) {
171
+ this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
172
+ }
173
+ }
174
+ }
package/src/worker.ts CHANGED
@@ -1,52 +1,27 @@
1
1
  /// <reference lib="webworker" />
2
2
  import init, { FluxParser } from "./wasm/flux_md_core.js";
3
- import type { FromWorker, ParserConfig, Patch, ToWorker } from "./types";
3
+ import type { ParserConfig } from "./types";
4
+ import { WorkerCore, type ParserLike } from "./worker-core";
4
5
 
5
6
  // Resolve the WASM asset with the *web-standard* `new URL(asset,
6
7
  // import.meta.url)` pattern (not Vite's `?url` suffix), so the package works in
7
- // any bundler with asset-module support — Vite, webpack 5, Rollup, Parcel.
8
- // wasm-bindgen's init() fetches a URL instance directly.
8
+ // any bundler with asset-module support — Vite, webpack 5, Rollup, Parcel, and
9
+ // Next.js (Turbopack/webpack). wasm-bindgen's init() fetches a URL instance.
9
10
  const wasmUrl = new URL("./wasm/flux_md_core_bg.wasm", import.meta.url);
10
11
 
11
- // One worker multiplexes many streams: a parser per stream id (the worker
12
- // pool). WASM is loaded once for the whole worker, shared by every parser.
13
- const parsers = new Map<number, FluxParser>();
14
- const config = new Map<number, ParserConfig>();
15
- const pending = new Map<number, string>();
16
- const totalAppended = new Map<number, number>();
17
- let flushScheduled = false;
18
- let wasmExports: any = null;
19
-
20
12
  const ctx: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope;
21
13
 
22
- function post(msg: FromWorker) {
23
- ctx.postMessage(msg);
24
- }
25
-
26
- async function setup() {
27
- // init() returns the wasm-bindgen instance; capture its `.memory` export so
28
- // we can report WASM-side memory usage on every patch. No parser yet — they
29
- // are created per stream, on demand.
30
- try {
31
- wasmExports = await init({ module_or_path: wasmUrl });
32
- post({ type: "ready" });
33
- } catch (e: unknown) {
34
- // WASM failed to load/instantiate: this worker can never parse anything.
35
- // Report it so the pool rejects whenReady() (rather than hanging forever)
36
- // and clients' onError fires. streamId is irrelevant for a worker-level
37
- // failure — the pool routes a fatal error to every stream it hosts.
38
- post({ type: "error", streamId: -1, message: e instanceof Error ? e.message : String(e), fatal: true });
39
- }
40
- }
14
+ // Captured from init() so we can report WASM-side memory usage on each patch.
15
+ let wasmExports: { memory?: { buffer?: ArrayBufferLike } } | null = null;
41
16
 
42
- function getOrCreate(streamId: number): FluxParser {
43
- let p = parsers.get(streamId);
44
- if (!p) {
45
- p = new FluxParser();
46
- // Per-stream config (sent on the stream's first message); omitted fields
47
- // fall back to the library defaults autolinks + alerts on (LLM output is
48
- // full of bare URLs and `> [!NOTE]` blocks), raw HTML escaped, footnotes off.
49
- const c = config.get(streamId);
17
+ // The message/readiness state machine lives in WorkerCore (testable without
18
+ // WASM); this shell injects the WASM-specific dependencies.
19
+ const core = new WorkerCore({
20
+ // Create + configure a parser for a stream. Omitted config fields fall back to
21
+ // the library defaults — autolinks + alerts on (LLM output is full of bare
22
+ // URLs and `> [!NOTE]` blocks), raw HTML escaped, footnotes/math off.
23
+ makeParser(c: ParserConfig | undefined): ParserLike {
24
+ const p = new FluxParser();
50
25
  p.setGfmAutolinks(c?.gfmAutolinks ?? true);
51
26
  p.setGfmAlerts(c?.gfmAlerts ?? true);
52
27
  p.setGfmFootnotes(c?.gfmFootnotes ?? false);
@@ -56,107 +31,35 @@ function getOrCreate(streamId: number): FluxParser {
56
31
  p.setUnsafeHtml(c?.unsafeHtml ?? false);
57
32
  p.setComponentTags(c?.componentTags ?? []);
58
33
  p.setBlockData(c?.blockData ?? false);
59
- parsers.set(streamId, p);
60
- }
61
- return p;
62
- }
63
-
64
- function dispose(streamId: number) {
65
- parsers.get(streamId)?.free();
66
- parsers.delete(streamId);
67
- config.delete(streamId);
68
- pending.delete(streamId);
69
- totalAppended.delete(streamId);
70
- }
71
-
72
- function wasmMemBytes(): number {
73
- try {
74
- return (wasmExports?.memory?.buffer?.byteLength as number) ?? 0;
75
- } catch {
76
- return 0;
77
- }
78
- }
79
-
80
- function emitPatch(streamId: number, patch: Patch, parser: FluxParser, parseMicros: number) {
81
- post({
82
- type: "patch",
83
- streamId,
84
- patch,
85
- appendedBytes: totalAppended.get(streamId) ?? 0,
86
- parseMicros,
87
- retainedBytes: parser.retainedBytes(),
88
- wasmMemoryBytes: wasmMemBytes(),
89
- });
90
- }
91
-
92
- function flush() {
93
- flushScheduled = false;
94
- if (pending.size === 0) return;
95
- // Process every stream with buffered input this microtask.
96
- for (const [streamId, chunk] of pending) {
97
- pending.delete(streamId);
98
- if (chunk.length === 0) continue;
99
- const parser = getOrCreate(streamId);
100
- const t0 = performance.now();
34
+ return p;
35
+ },
36
+ post: (msg) => ctx.postMessage(msg),
37
+ memBytes: () => {
101
38
  try {
102
- const patch = parser.append(chunk) as Patch;
103
- const dt = (performance.now() - t0) * 1000;
104
- totalAppended.set(streamId, (totalAppended.get(streamId) ?? 0) + chunk.length);
105
- emitPatch(streamId, patch, parser, dt);
106
- } catch (e: unknown) {
107
- post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
39
+ return (wasmExports?.memory?.buffer?.byteLength as number) ?? 0;
40
+ } catch {
41
+ return 0;
108
42
  }
109
- }
110
- }
43
+ },
44
+ schedule: (fn) => queueMicrotask(fn),
45
+ });
111
46
 
112
- function scheduleFlush() {
113
- if (flushScheduled) return;
114
- flushScheduled = true;
115
- queueMicrotask(flush);
116
- }
47
+ ctx.addEventListener("message", (ev: MessageEvent) => core.handle(ev.data));
117
48
 
118
- ctx.addEventListener("message", (ev: MessageEvent<ToWorker>) => {
119
- const msg = ev.data;
120
- const id = msg.streamId;
121
- // Stash any per-stream config carried on the first message (FIFO guarantees
122
- // it arrives before the parser is created in flush/finalize).
123
- if ((msg.type === "append" || msg.type === "finalize") && msg.config) {
124
- config.set(id, msg.config);
125
- }
126
- switch (msg.type) {
127
- case "append":
128
- pending.set(id, (pending.get(id) ?? "") + msg.chunk);
129
- scheduleFlush();
130
- break;
131
- case "finalize": {
132
- // Drain any buffered input for this stream, then finalize.
133
- const buffered = pending.get(id);
134
- pending.delete(id);
135
- const parser = getOrCreate(id);
136
- try {
137
- if (buffered && buffered.length > 0) {
138
- parser.append(buffered);
139
- totalAppended.set(id, (totalAppended.get(id) ?? 0) + buffered.length);
140
- }
141
- const patch = parser.finalize() as Patch;
142
- emitPatch(id, patch, parser, 0);
143
- } catch (e: unknown) {
144
- post({ type: "error", streamId: id, message: e instanceof Error ? e.message : String(e) });
145
- }
146
- break;
147
- }
148
- case "reset":
149
- // Free and recreate lazily on the next append — same stream id, **same
150
- // config** (kept). The client resets its local state synchronously.
151
- parsers.get(id)?.free();
152
- parsers.delete(id);
153
- pending.delete(id);
154
- totalAppended.delete(id);
155
- break;
156
- case "dispose":
157
- dispose(id);
158
- break;
49
+ async function setup() {
50
+ // init() returns the wasm-bindgen instance; capture its `.memory` export for
51
+ // the per-patch memory metric. No parser yet — they are created per stream,
52
+ // on demand, only after markReady() opens the gate.
53
+ try {
54
+ wasmExports = await init({ module_or_path: wasmUrl });
55
+ core.markReady();
56
+ } catch (e: unknown) {
57
+ // WASM failed to load/instantiate: this worker can never parse anything.
58
+ // Report it so the pool rejects whenReady() (rather than hanging forever)
59
+ // and clients' onError fires. streamId is irrelevant for a worker-level
60
+ // failure — the pool routes a fatal error to every stream it hosts.
61
+ ctx.postMessage({ type: "error", streamId: -1, message: e instanceof Error ? e.message : String(e), fatal: true });
159
62
  }
160
- });
63
+ }
161
64
 
162
65
  setup();