flux-md 0.16.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +950 -0
  2. package/README.md +111 -82
  3. package/dist/block-props.d.ts +18 -0
  4. package/dist/block-props.js +75 -0
  5. package/dist/client.d.ts +311 -0
  6. package/{src/client.ts → dist/client.js} +143 -325
  7. package/dist/dom.d.ts +110 -0
  8. package/dist/dom.js +576 -0
  9. package/dist/element.d.ts +20 -0
  10. package/dist/element.js +287 -0
  11. package/dist/hi.d.ts +12 -0
  12. package/{src/hi.ts → dist/hi.js} +42 -74
  13. package/dist/html-to-react.d.ts +40 -0
  14. package/dist/html-to-react.js +344 -0
  15. package/{src/index.ts → dist/index.d.ts} +5 -25
  16. package/dist/index.js +16 -0
  17. package/dist/morph.d.ts +28 -0
  18. package/dist/morph.js +166 -0
  19. package/dist/react.d.ts +204 -0
  20. package/dist/react.js +490 -0
  21. package/dist/renderers/CodeBlock.d.ts +7 -0
  22. package/dist/renderers/CodeBlock.js +75 -0
  23. package/dist/renderers/Math.d.ts +14 -0
  24. package/dist/renderers/Math.js +15 -0
  25. package/dist/renderers/Mermaid.d.ts +13 -0
  26. package/dist/renderers/Mermaid.js +15 -0
  27. package/dist/server-react.d.ts +32 -0
  28. package/dist/server-react.js +48 -0
  29. package/dist/server.d.ts +31 -0
  30. package/dist/server.js +81 -0
  31. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  32. package/dist/solid.js +54 -0
  33. package/dist/svelte.d.ts +80 -0
  34. package/dist/svelte.js +59 -0
  35. package/dist/types-core.d.ts +382 -0
  36. package/dist/types-core.js +0 -0
  37. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  38. package/dist/types-react.js +0 -0
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.js +2 -0
  41. package/dist/vue.d.ts +94 -0
  42. package/dist/vue.js +79 -0
  43. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  44. package/{src → dist}/wasm/flux_md_core.js +50 -55
  45. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  46. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  47. package/dist/worker-core.d.ts +65 -0
  48. package/dist/worker-core.js +154 -0
  49. package/dist/worker.d.ts +1 -0
  50. package/dist/worker.js +48 -0
  51. package/package.json +25 -19
  52. package/src/block-props.ts +0 -141
  53. package/src/dom.ts +0 -946
  54. package/src/element.ts +0 -400
  55. package/src/html-to-react.ts +0 -455
  56. package/src/morph.ts +0 -253
  57. package/src/react.tsx +0 -1020
  58. package/src/renderers/CodeBlock.tsx +0 -116
  59. package/src/renderers/Math.tsx +0 -28
  60. package/src/renderers/Mermaid.tsx +0 -27
  61. package/src/server.tsx +0 -221
  62. package/src/svelte.ts +0 -179
  63. package/src/types-core.ts +0 -398
  64. package/src/types.ts +0 -7
  65. package/src/vue.ts +0 -184
  66. package/src/wasm/flux_md_core_bg.wasm +0 -0
  67. package/src/worker-core.ts +0 -174
  68. package/src/worker.ts +0 -72
  69. /package/{src → dist}/styles.css +0 -0
package/README.md CHANGED
@@ -12,19 +12,23 @@ Parsing runs entirely **off the main thread** — each stream gets its own poole
12
12
  bun add flux-md # or: npm i flux-md / pnpm add flux-md
13
13
  ```
14
14
 
15
- flux-md ships as **source** (TypeScript + the compiled WASM). The worker and
16
- WASM asset are referenced with the **web-standard `new URL(asset,
15
+ flux-md ships **compiled, non-minified ESM** (`dist/*.js` + `.d.ts` types) plus
16
+ the compiled WASM no raw `.ts`/`.tsx` source. The worker and WASM asset are
17
+ referenced with the **web-standard `new URL(asset,
17
18
  import.meta.url)`** pattern, so any bundler with asset-module support resolves
18
19
  them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
19
20
  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
+ **verified on Next.js 16**, see the [Next.js callout](#nextjs) below).
22
+
21
23
  The streaming client (`<FluxMarkdown>` / `FluxClient`) is **browser-only** (it
22
24
  constructs Web Workers). For **server-side / static rendering of finished
23
25
  content** — SSR, React Server Components, build steps — use the worker-free,
24
26
  synchronous [`flux-md/server`](#server-side-rendering) entry. The framework packages — `react`,
25
27
  `vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
26
- need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
27
- `flux-md/dom`, `flux-md/element`) needs none.
28
+ need the one whose binding you import. The framework-free entries
29
+ (`flux-md/client`, `flux-md/dom`, `flux-md/element`, and `flux-md/server`) need
30
+ none. (The bare `flux-md` entry re-exports the React component surface, so it
31
+ pulls `react` — import from `flux-md/client` if you want a framework-free core.)
28
32
 
29
33
  > **Vite — one-line config.** Vite's dependency pre-bundling (esbuild) hoists
30
34
  > the wasm-bindgen glue into `.vite/deps/`, which breaks the relative
@@ -42,43 +46,35 @@ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
42
46
 
43
47
  <a id="nextjs"></a>
44
48
 
45
- > **Next.js (App Router) — two requirements.** Verified on **Next.js 16** with
46
- > **Turbopack** (the default for both `next dev` and `next build`). The same two
47
- > requirements apply under webpack. Because flux-md ships TypeScript source:
48
- >
49
- > 1. **Transpile the package.** Next does not compile `node_modules` TypeScript
50
- > by default without this, Turbopack errors with *"Unknown module type"* on
51
- > `react.tsx`. Add flux-md to `transpilePackages`:
52
- >
53
- > ```ts
54
- > // next.config.ts
55
- > import type { NextConfig } from "next";
56
- > const nextConfig: NextConfig = { transpilePackages: ["flux-md"] };
57
- > export default nextConfig;
58
- > ```
49
+ > **Next.js (App Router) — one requirement.** Works on **Next.js** with
50
+ > **Turbopack** (the default for both `next dev` and `next build`) or webpack.
51
+ > Since 0.17.0 flux-md ships **compiled ESM**, so **no `transpilePackages` or
52
+ > other build config is needed** — earlier versions required it only because the
53
+ > package shipped raw TypeScript, which Next does not compile inside
54
+ > `node_modules`. That no longer applies.
59
55
  >
60
- > 2. **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
61
- > spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
62
- > a Server Component. (It is still SSR-safe: on the server it renders an empty
63
- > shell and only starts streaming after hydration, so there's no SSR crash —
64
- > the constraint is hooks, not the worker.)
56
+ > **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
57
+ > spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
58
+ > a Server Component. (It is still SSR-safe: on the server it renders an empty
59
+ > shell and only starts streaming after hydration, so there's no SSR crash —
60
+ > the constraint is hooks, not the worker.)
65
61
  >
66
- > ```tsx
67
- > "use client";
68
- > import { FluxMarkdown } from "flux-md/react";
62
+ > ```tsx
63
+ > "use client";
64
+ > import { FluxMarkdown } from "flux-md/react";
69
65
  >
70
- > export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
71
- > return <FluxMarkdown stream={stream} />;
72
- > }
73
- > ```
66
+ > export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
67
+ > return <FluxMarkdown stream={stream} />;
68
+ > }
69
+ > ```
74
70
  >
75
- > **Create the `stream` in Client Component code, not in a Server Component.**
76
- > A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
77
- > can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
78
- > throws *"Only plain objects can be passed to Client Components."* Pass a
79
- > serializable prop (a URL, the chat messages) from the server and open the
80
- > stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
81
- > effect, or the `useFluxStream` hook (see [Quick start](#quick-start)).
71
+ > **Create the `stream` in Client Component code, not in a Server Component.**
72
+ > A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
73
+ > can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
74
+ > throws *"Only plain objects can be passed to Client Components."* Pass a
75
+ > serializable prop (a URL, the chat messages) from the server and open the
76
+ > stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
77
+ > effect, or the `useFluxStream` hook (see [Quick start](#quick-start)).
82
78
  >
83
79
  > That's it — Turbopack bundles the worker and emits the `.wasm` to
84
80
  > `_next/static/media` itself, so no extra asset/loader config is needed (and the
@@ -425,12 +421,15 @@ const html = renderToString("# Hello\n\n**world**"); // sync HTML string, no w
425
421
  ```
426
422
 
427
423
  For React server rendering (RSC, static generation, or SSR), use
428
- `<FluxMarkdownStatic>` — a hookless, RSC-safe component that renders finished
429
- content with the same `components` overrides (inline/block component tags
430
- dispatch on the server too):
424
+ `<FluxMarkdownStatic>` from **`flux-md/server/react`** — a hookless, RSC-safe
425
+ component that renders finished content with the same `components` overrides
426
+ (inline/block component tags dispatch on the server too). It lives in its own
427
+ subpath so the core `flux-md/server` above stays importable with no `react`
428
+ installed:
431
429
 
432
430
  ```tsx
433
- import { initFlux, FluxMarkdownStatic } from "flux-md/server";
431
+ import { initFlux } from "flux-md/server";
432
+ import { FluxMarkdownStatic } from "flux-md/server/react";
434
433
 
435
434
  await initFlux();
436
435
  export default function Doc({ md }: { md: string }) {
@@ -449,10 +448,11 @@ export default function Doc({ md }: { md: string }) {
449
448
  bundler-resolved asset. On edge runtimes pass bytes yourself:
450
449
  `initFluxSync(wasmBytes)`.
451
450
  - **`renderToString(md, { config })`** — synchronous HTML string, **zero React
452
- dependency**.
451
+ dependency** (imports cleanly with no `react` installed).
453
452
  - **`parseToBlocks(md, { config })`** — the block array, for custom rendering.
454
- - **`<FluxMarkdownStatic content config components />`** — synchronous React tree
455
- for **render-once** contexts; render it with your framework's server renderer
453
+ - **`<FluxMarkdownStatic content config components />`** (from
454
+ `flux-md/server/react`) — synchronous React tree for **render-once** contexts;
455
+ render it with your framework's server renderer
456
456
  (`renderToStaticMarkup`, RSC, …). For live streaming, client-side code
457
457
  highlighting, or Mermaid, render `<FluxMarkdown>` on the client instead — it's a
458
458
  separate component. (If you SSR-then-hydrate, use the *same* component on both
@@ -999,42 +999,71 @@ genuinely hostile content where CSS-overlay/clickjacking matters, render inside
999
999
  a sandboxed `<iframe>` instead — sanitization stops injection, not every
1000
1000
  visual-overlay trick.
1001
1001
 
1002
- ### Supply-chain transparency
1003
-
1004
- flux-md is **zero runtime dependency** — no third-party packages are pulled in
1005
- at runtime. The parsing core is Rust compiled to WebAssembly, reproducibly
1006
- buildable from `crates/flux-md-core/` via `bun run build:wasm`.
1007
-
1008
- **Native code (WebAssembly).** The shipped `flux_md_core_bg.wasm` (~200 KB) is
1009
- first-party, built from the Rust source in this repo, and runs inside a sandboxed
1010
- Web Worker (browser) or Node worker thread. Supply-chain scanners such as
1011
- [Socket.dev](https://socket.dev) will flag it as `nativeCode` — this is accurate
1012
- and expected. The WASM is not a vendored third-party binary; it is reproducible
1013
- from source.
1014
-
1015
- **Network access.** flux-md performs network I/O in exactly two scenarios, both
1016
- caller-driven:
1017
-
1018
- - `<flux-markdown src="URL">` — the Web Component fetches the URL you supply and
1019
- streams the response. No URL is ever chosen by flux-md itself.
1020
- - The wasm-bindgen glue (`wasm/flux_md_core.js`) loads the co-located `.wasm`
1021
- asset via `fetch(new URL("…_bg.wasm", import.meta.url))`bundlers resolve
1022
- this to a local build artifact, not a remote endpoint.
1023
-
1024
- flux-md has no telemetry, no analytics, and no first-party remote endpoints.
1025
- Socket will flag the `networkAccess` signal — it is accurate and expected. In
1026
- privileged contexts (browser extensions, Electron, environments where the
1027
- same-origin policy may not apply), treat the `src` attribute value as you would
1028
- any external URL and allowlist it in your CSP / security policy.
1029
-
1030
- **Filesystem access (Node/SSR only).** `flux-md/server` reads the package's
1031
- own `.wasm` file off disk on Node.js (Node's `fetch` cannot load `file://`
1032
- URLs). This is a Node-only path; it reads only the package-internal asset and
1033
- never touches caller-supplied paths. Socket will flag `filesystemAccess` — also
1034
- accurate and expected.
1035
-
1036
- The `socket.yml` at the repository root documents these signals with their
1037
- justifications for Socket's GitHub app.
1002
+ ### Supply chain & security posture
1003
+
1004
+ flux-md ships **zero runtime dependencies** — `dependencies` and
1005
+ `optionalDependencies` in `package.json` are both empty. The parsing core is Rust
1006
+ compiled to WebAssembly, reproducibly buildable from `crates/flux-md-core/` via
1007
+ `bun run build:wasm`. The package publishes **compiled, non-minified ESM**
1008
+ (`dist/*.js` + `.d.ts`); it does not ship raw `.ts`/`.tsx` source.
1009
+
1010
+ **Frameworks are optional peers, by design.** `react`, `vue`, `svelte`, and
1011
+ `solid-js` are declared as `peerDependencies` with
1012
+ `peerDependenciesMeta.optional: true`. You install only the one you use or none
1013
+ (the `flux-md/dom` and `flux-md/element` entries need no framework at all). This
1014
+ is the most important supply-chain property of the package: **a React-only
1015
+ consumer never installs `vue` or `solid-js`, so those frameworks' transitive
1016
+ internals never enter that consumer's lockfile.** `npm i flux-md` on its own pulls
1017
+ in nothing else.
1018
+
1019
+ **Why a registry scan may flag `seroval` and `@vue/compiler-*`.** When a scanner
1020
+ resolves *all* declared peers, it surfaces alerts on framework internals reachable
1021
+ only through the optional peers**none of which is flux-md code, and none of
1022
+ which is installed unless you opt into that framework:**
1023
+
1024
+ - `seroval` (transitive of **solid-js**) its `deserialize()` uses
1025
+ `(0, eval)(source)` and touches the network. This is Solid's SSR serialization
1026
+ layer; it is also the package some scanners label a "potential vulnerability".
1027
+ - `@vue/compiler-core` (transitive of **vue**) uses the `Function` constructor
1028
+ for template codegen.
1029
+ - `@vue/compiler-sfc` (transitive of **vue**) — references `globalThis["fetch"]`.
1030
+ - minified esm-bundler builds of those compilers read as "obfuscated code".
1031
+
1032
+ flux-md's own source contains **no `eval` and no `Function(...)` constructor**
1033
+ (`grep -rnE '\beval\s*\(|\bnew Function\b|\bFunction\s*\(' packages/flux-md/src`
1034
+ returns nothing). The
1035
+ repository's [`socket.yml`](https://github.com/siinghd/flux-md/blob/main/socket.yml)
1036
+ documents this and disables those upstream-framework alert types for flux-md's own
1037
+ CI (which installs every framework as a devDependency for cross-framework tests).
1038
+ If you prefer surgical handling, ignore the specific transitive packages instead
1039
+ (e.g. `@SocketSecurity ignore seroval@<version>`).
1040
+
1041
+ **flux-md is browser-oriented (Web Worker + WASM).** The default path runs the
1042
+ WASM parser inside a Web Worker — ideal for browsers and modern Node
1043
+ (`worker_threads`), but **not** intended for non-browser or older environments
1044
+ that lack Workers/WASM. If you need a worker-free, synchronous path (Node SSR /
1045
+ React Server Components), use **`flux-md/server`** — it loads the same WASM
1046
+ synchronously off disk and renders to a string without spawning a worker.
1047
+
1048
+ **First-party signals a scanner will (correctly) show.** These describe flux-md
1049
+ itself and are kept *visible* rather than silenced:
1050
+
1051
+ - **Native code (`hasNativeCode`).** The first-party `dist/wasm/flux_md_core_bg.wasm`
1052
+ (~180 KB) is built from the Rust source in this repo and runs inside a sandboxed
1053
+ Web Worker (browser) or Node worker thread. It is reproducible from source, not a
1054
+ vendored third-party binary.
1055
+ - **Network access (`networkAccess`).** Only `<flux-markdown src="URL">` (the URL
1056
+ *you* supply) and the wasm-bindgen glue loading the co-located `.wasm` via
1057
+ `fetch(new URL("…_bg.wasm", import.meta.url))` — which bundlers resolve to a
1058
+ local build artifact. No telemetry, no analytics, no first-party remote
1059
+ endpoints. In privileged contexts (browser extensions, Electron) treat the `src`
1060
+ value as any external URL and allowlist it in your CSP.
1061
+ - **Filesystem access (`filesystemAccess`).** Node/SSR only: `flux-md/server` reads
1062
+ the package's own `.wasm` off disk (Node's `fetch` cannot load `file://` URLs).
1063
+ It reads only the package-internal asset, never a caller-supplied path.
1064
+
1065
+ The `socket.yml` at the repository root documents every signal with its
1066
+ justification for Socket's GitHub app.
1038
1067
 
1039
1068
  ## Scaling
1040
1069
 
@@ -1130,7 +1159,7 @@ re-snapping during streaming, so treat smooth following there as best-effort.
1130
1159
  │ microtask, calls WASM │
1131
1160
  └──┬──── ffi ───────────────────────────┘
1132
1161
 
1133
- ┌── Rust → WASM (~150 KB after opt) ────┐
1162
+ ┌── Rust → WASM (~170 KB after opt) ────┐
1134
1163
  │ StreamParser: │
1135
1164
  │ buffer: append-only │
1136
1165
  │ committed_offset │
@@ -0,0 +1,18 @@
1
+ import type { Block, BlockComponentProps } from "./types-core.js";
2
+ /** Info-string language from a code block's `data-lang="…"`. */
3
+ export declare function extractLang(html: string): string;
4
+ /**
5
+ * Convert sanitized HTML attribute pairs into a spreadable object, keeping the
6
+ * HTML-form names (`class`, `for`) verbatim. This is the deliberate divergence
7
+ * from the JSX renderer (which renames to `className`/`htmlFor` for a prop
8
+ * spread): the DOM renderer applies them via `el.setAttribute(name, value)`,
9
+ * which wants the literal HTML names.
10
+ */
11
+ export declare function htmlAttrs(pairs: [string, string][]): Record<string, string>;
12
+ /**
13
+ * Build the props a block-kind / component-tag override receives — the same
14
+ * shape the JSX renderer's block-kind props carry, with ONE deliberate
15
+ * divergence: for `Component` blocks `attrs` stay in HTML form (`class`/`for`)
16
+ * because DOM overrides apply them via `setAttribute` (see {@link htmlAttrs}).
17
+ */
18
+ export declare function blockProps(block: Block): BlockComponentProps;
@@ -0,0 +1,75 @@
1
+ function decodeEntities(s) {
2
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
3
+ }
4
+ function decodeCodeText(html) {
5
+ const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
6
+ return m ? decodeEntities(m[1]) : "";
7
+ }
8
+ function decodeMathText(html) {
9
+ const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
10
+ if (d) return decodeEntities(d[1]);
11
+ return decodeCodeText(html);
12
+ }
13
+ function extractLang(html) {
14
+ const m = html.match(/data-lang="([^"]+)"/);
15
+ return m ? m[1] : "";
16
+ }
17
+ function componentInnerHtml(html, tag) {
18
+ const gt = html.indexOf(">");
19
+ if (gt < 0) return "";
20
+ let inner = html.slice(gt + 1);
21
+ const close = `</${tag}>`;
22
+ if (inner.endsWith(close)) inner = inner.slice(0, -close.length);
23
+ return inner.replace(/^\n/, "").replace(/\n$/, "");
24
+ }
25
+ function htmlAttrs(pairs) {
26
+ const out = {};
27
+ for (const [k, v] of pairs) out[k] = v;
28
+ return out;
29
+ }
30
+ function blockProps(block) {
31
+ const props = {
32
+ block,
33
+ html: block.html,
34
+ open: block.open,
35
+ speculative: block.speculative
36
+ };
37
+ const data = block.kind.data;
38
+ if (block.kind.type === "CodeBlock") {
39
+ props.text = data?.code ?? decodeCodeText(block.html);
40
+ props.language = data?.lang ?? "";
41
+ if (typeof data?.code === "string") {
42
+ props.code = { lang: data.lang ?? null, code: data.code };
43
+ }
44
+ } else if (block.kind.type === "MathBlock") {
45
+ props.text = data?.latex ?? decodeMathText(block.html);
46
+ if (typeof data?.latex === "string") {
47
+ props.math = { latex: data.latex };
48
+ }
49
+ } else if (block.kind.type === "List") {
50
+ if (data && typeof data.start === "number") {
51
+ props.list = { ordered: !!data.ordered, start: data.start, items: data.items };
52
+ }
53
+ } else if (block.kind.type === "Component") {
54
+ props.tag = data?.tag ?? "";
55
+ props.attrs = htmlAttrs(data?.attrs ?? []);
56
+ props.html = componentInnerHtml(block.html, props.tag);
57
+ } else if (block.kind.type === "Table") {
58
+ props.table = block.kind.data;
59
+ } else if (block.kind.type === "Heading") {
60
+ if (typeof block.kind.data === "object" && block.kind.data !== null) {
61
+ props.heading = block.kind.data;
62
+ }
63
+ } else if (block.kind.type === "Blockquote" || block.kind.type === "Alert") {
64
+ const cd = block.kind.data;
65
+ if (cd && Array.isArray(cd.nested)) {
66
+ props.container = { nested: cd.nested };
67
+ }
68
+ }
69
+ return props;
70
+ }
71
+ export {
72
+ blockProps,
73
+ extractLang,
74
+ htmlAttrs
75
+ };
@@ -0,0 +1,311 @@
1
+ import type { Block, FromWorker, ParserConfig, Patch, ToWorker, WorkerLike } from "./types-core.js";
2
+ /**
3
+ * The ordered-block store backing a stream, extracted as a pure function so
4
+ * its reference-stability contract is testable without a Worker.
5
+ *
6
+ * **The contract that prevents extra React re-renders:** a block, once
7
+ * committed, is never re-sent by the parser, so `applyPatch` never replaces it
8
+ * in the map. Its object reference stays identical across every later patch —
9
+ * which is exactly what `blocksEqual` (the BlockView memo) checks, so committed
10
+ * blocks never re-render (and never re-parse) as the stream grows. Only the
11
+ * `active` tail gets fresh references each patch, and only it re-renders.
12
+ */
13
+ export interface BlockStore {
14
+ committed: Map<number, Block>;
15
+ committedOrder: number[];
16
+ active: Block[];
17
+ snapshot: Block[];
18
+ }
19
+ export declare function emptyBlockStore(): BlockStore;
20
+ /** A heading entry for building a table of contents — see {@link FluxClient.outline}. */
21
+ export interface OutlineEntry {
22
+ /** Heading level 1–6. */
23
+ level: number;
24
+ /** Plain-text heading content (tags stripped, entities decoded). */
25
+ text: string;
26
+ /** Stable block id — usable as a scroll target / React key. */
27
+ id: number;
28
+ }
29
+ export declare function applyPatch(store: BlockStore, patch: Patch): void;
30
+ interface PoolWorker {
31
+ worker: WorkerLike;
32
+ ready: boolean;
33
+ /** Set once WASM init fails; whenWorkerReady rejects with this thereafter. */
34
+ failed: Error | null;
35
+ streamCount: number;
36
+ /** Live stream ids on this worker — so a fatal failure can notify each one. */
37
+ streamIds: Set<number>;
38
+ readyWaiters: Array<{
39
+ resolve: () => void;
40
+ reject: (e: Error) => void;
41
+ }>;
42
+ }
43
+ /**
44
+ * A pool of Web Workers, each multiplexing many `FluxParser`s keyed by stream
45
+ * id. This is what lets flux-md scale past `hardwareConcurrency` concurrent
46
+ * streams without oversubscribing OS threads: 50 streams share (at most) the
47
+ * cap's worth of workers instead of spawning 50.
48
+ *
49
+ * Worker creation is **lazy and load-aware**: while under the cap, each new
50
+ * stream gets its own worker (so 1 stream = 1 worker, identical to the old
51
+ * behavior); once at the cap, new streams attach to the least-loaded worker.
52
+ *
53
+ * The constructor injects a `WorkerLike` factory so the routing and lifecycle
54
+ * logic is unit-testable with a fake worker — no real Worker or WASM needed.
55
+ */
56
+ export declare class FluxPool {
57
+ private factory;
58
+ private cap;
59
+ private workers;
60
+ private handlers;
61
+ private nextStreamId;
62
+ constructor(factory: () => WorkerLike, cap: number);
63
+ /** Reserve a stream id and assign a worker, registering its message handler. */
64
+ acquire(handler: (msg: FromWorker) => void): {
65
+ streamId: number;
66
+ pw: PoolWorker;
67
+ };
68
+ /** Free a stream's parser in its worker; keep the worker warm for siblings. */
69
+ release(streamId: number, pw: PoolWorker): void;
70
+ /** Inverse of {@link release}: re-register a stream's handler so it receives
71
+ * patches again. For React StrictMode's dev double-mount, which destroys a
72
+ * client on the simulated unmount and remounts the SAME instance. The worker
73
+ * lazily recreates the disposed parser on the next append. */
74
+ reattach(streamId: number, pw: PoolWorker, handler: (msg: FromWorker) => void): void;
75
+ send(pw: PoolWorker, msg: ToWorker): void;
76
+ /** Resolves when the given worker has finished WASM init; rejects if it failed. */
77
+ whenWorkerReady(pw: PoolWorker): Promise<void>;
78
+ /**
79
+ * Eagerly spin up one worker so WASM init starts BEFORE the first stream —
80
+ * taking the one-time init off the first-token critical path (e.g. call
81
+ * `getDefaultPool().warm()` on app load / route entry). Reuses a live worker
82
+ * if one exists; the warm worker is the one the first stream attaches to (it
83
+ * has spare capacity), so the work is not wasted. Resolves when that worker has
84
+ * finished initializing WASM; rejects if init fails fatally. Browser-only (it
85
+ * constructs a `Worker`).
86
+ */
87
+ warm(): Promise<void>;
88
+ /** Terminate every worker (test teardown / full shutdown). */
89
+ disposeAll(): void;
90
+ get workerCount(): number;
91
+ private pick;
92
+ private create;
93
+ private onMessage;
94
+ private dispatch;
95
+ }
96
+ /** The process-wide default pool every `FluxClient` shares unless given one. */
97
+ export declare function getDefaultPool(): FluxPool;
98
+ /** TEST-ONLY: drop the process-wide default pool so the next {@link getDefaultPool}
99
+ * rebuilds it (lazily, with the current global `Worker`). Lets a test file that
100
+ * drives the default pool start from a clean, deterministic state regardless of
101
+ * which other file warmed it first in bun's shared test process. Not part of the
102
+ * public API and a no-op for normal runtime use. */
103
+ export declare function __resetDefaultPool(): void;
104
+ /**
105
+ * Subscriber-driven store backing a single streaming parser. Each client owns
106
+ * one stream within a shared {@link FluxPool}; many clients multiplex over a
107
+ * small set of workers (see the pool for the scaling story).
108
+ *
109
+ * The store exposes:
110
+ * - subscribe(listener): for React's useSyncExternalStore
111
+ * - getSnapshot(): the current ordered list of blocks
112
+ * - getMetrics(): per-stream perf metrics
113
+ *
114
+ * Mutation methods:
115
+ * - append(chunk): forward to the worker
116
+ * - finalize(): mark the stream done
117
+ * - reset(): start fresh
118
+ */
119
+ export declare class FluxClient {
120
+ private pool;
121
+ private pw;
122
+ private streamId;
123
+ private config?;
124
+ private configSent;
125
+ private listeners;
126
+ private store;
127
+ private onError?;
128
+ private onBlock?;
129
+ private attached;
130
+ private lastContent;
131
+ private contentDone;
132
+ private coalesce;
133
+ private rafHandle;
134
+ private finalizePending;
135
+ private epoch;
136
+ private appendedBytes;
137
+ private patchCount;
138
+ private totalParseMicros;
139
+ private lastPatchMs;
140
+ private firstAppendMs;
141
+ private retainedBytes;
142
+ private wasmMemoryBytes;
143
+ private renderCount;
144
+ private rebuildCount;
145
+ /**
146
+ * @param options.pool worker pool to join (defaults to the shared
147
+ * process-wide pool — pass a dedicated `FluxPool` only for isolation).
148
+ * @param options.config per-stream parser flags (see {@link ParserConfig});
149
+ * omitted fields use library defaults. Applied once, immutable thereafter.
150
+ * @param options.onError invoked on a worker/parse error or a fatal WASM-init
151
+ * failure (`fatal: true`). Without it, errors are only `console.error`d and
152
+ * a load failure surfaces solely as a rejected {@link FluxClient.whenReady}.
153
+ * @param options.onBlock invoked once per block as it commits (in document
154
+ * order, after the store updates) — for side effects like lazily
155
+ * highlighting a finished code block or analytics. A committed block never
156
+ * re-fires; the streaming tail does not (subscribe for live tail updates).
157
+ * @param options.coalesce opt-in (default `false`): collapse multiple
158
+ * intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
159
+ * flush to subscribers, so a React `useSyncExternalStore` consumer renders at
160
+ * most once per frame instead of once per patch. Lossless — committed blocks
161
+ * are reference-stable, so only superseded tail-only renders are skipped. The
162
+ * stream-completion (finalize) patch always flushes synchronously, and a
163
+ * pending frame is cancelled on `reset()`/`destroy()`. No effect when
164
+ * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
165
+ */
166
+ constructor(options?: {
167
+ pool?: FluxPool;
168
+ config?: ParserConfig;
169
+ onError?: (err: {
170
+ message: string;
171
+ fatal?: boolean;
172
+ }) => void;
173
+ onBlock?: (block: Block) => void;
174
+ coalesce?: boolean;
175
+ });
176
+ /**
177
+ * Lazily reserve this client's stream id and bind it to a pool worker. The
178
+ * SOLE place that calls pool.acquire() — so the worker is created on the FIRST
179
+ * worker-bound operation (append/finalize/reset/pipeFrom/whenReady), never at
180
+ * construct time. This is what makes `new FluxClient()` SSR-safe: nothing here
181
+ * runs during an SSR render (which only subscribes + reads the snapshot).
182
+ *
183
+ * Idempotent: once this.pw is set it returns it immediately and never
184
+ * re-acquires — this.pw is never nulled (destroy() deliberately keeps it so
185
+ * StrictMode's destroy()→reattach() on the SAME instance re-registers the same
186
+ * slot). Note: streamId/worker assignment now follows first-worker-bound-op
187
+ * order, not construction order — a client constructed first no longer
188
+ * necessarily owns the lowest streamId. This affects neither the pool cap nor
189
+ * multiplexing (pick() is unchanged and remains the only path to create()).
190
+ */
191
+ private ensureAcquired;
192
+ get ready(): boolean;
193
+ whenReady(): Promise<void>;
194
+ private firstConfig;
195
+ append(chunk: string): void;
196
+ finalize(): void;
197
+ /**
198
+ * Pipe a source straight in: read it to completion, `append()` each chunk,
199
+ * then `finalize()`. The LLM-native path — e.g.
200
+ * `await client.pipeFrom(await fetch("/api/chat"))`. Accepts:
201
+ * - a `Response` or its `ReadableStream<Uint8Array>` body (bytes; decoded
202
+ * with `TextDecoder({ stream: true })` so a multibyte sequence straddling
203
+ * a chunk boundary carries into the next read), or
204
+ * - an `AsyncIterable<string>` (e.g. an SSE delta generator) — string chunks
205
+ * appended verbatim.
206
+ *
207
+ * Pass `opts.signal` to supersede/cancel: the signal is checked on every
208
+ * iteration, so once aborted no further chunk is appended and **finalize is
209
+ * skipped** (a superseded stream must not finalize). For a byte source the
210
+ * reader is also `cancel()`'d to tear down the upstream. Resolves once
211
+ * finalized (or cleanly on abort); rejects if the source itself errors.
212
+ * Browser-only for byte sources (uses `TextDecoder`).
213
+ */
214
+ pipeFrom(source: ReadableStream<Uint8Array> | Response | AsyncIterable<string>, opts?: {
215
+ signal?: AbortSignal;
216
+ }): Promise<void>;
217
+ /**
218
+ * Drive the parser from a CONTROLLED full string instead of manual appends.
219
+ * Pass the whole document-so-far each time; setContent diffs it against the
220
+ * last value and does the minimal work:
221
+ * - **prefix-extension** (the streaming-growth case) → append only the new
222
+ * suffix, so committed blocks stay put and only the active tail re-parses;
223
+ * - **any other change** (e.g. a finished stream swapped for a re-processed
224
+ * final string) → `reset()` + reparse the whole new string.
225
+ *
226
+ * This is the first-class bridge for UIs that hold a streaming message as a
227
+ * single growing string prop (the common React shape) — no hand-rolled diff,
228
+ * no readiness gate (appends before WASM is ready are buffered). Pass
229
+ * `{ done: true }` once the content is final to `finalize()` (idempotent within
230
+ * a generation; a content change *after* done reopens the stream via a fresh
231
+ * reparse, since a finalized parser is terminal and can't be appended to).
232
+ * Drive a given client with `setContent` *or* manual `append()`/`finalize()`,
233
+ * not both — they share the internal diff baseline.
234
+ *
235
+ * v1 note: the non-prefix path is a full reparse, not a partial rewind —
236
+ * committed blocks are frozen, so there is no truncate-to-offset. For the
237
+ * common case (append-growth + one end-of-stream swap) that is optimal. A
238
+ * transform that rewrites *earlier* bytes on every update is an anti-pattern
239
+ * here (it forces a reparse each tick); do that enrichment at render time via
240
+ * `components` instead, keeping the source append-only.
241
+ */
242
+ setContent(content: string, opts?: {
243
+ done?: boolean;
244
+ }): void;
245
+ reset(): void;
246
+ destroy(): void;
247
+ /**
248
+ * Re-register with the pool after {@link destroy} so the client receives
249
+ * patches again. Needed only for React StrictMode's dev double-mount, where
250
+ * the renderer destroys on the simulated unmount then remounts the SAME
251
+ * client instance; apps don't normally call this. No-op if still attached.
252
+ */
253
+ reattach(): void;
254
+ subscribe: (fn: () => void) => () => boolean;
255
+ getSnapshot: () => Block[];
256
+ /**
257
+ * Internal: a renderer with an `onRenderMetrics` hook calls this once per
258
+ * actual React block render so `getMetrics().renderCount` aggregates churn.
259
+ * No-op cost when no hook is wired (it is simply never called). Not part of
260
+ * the public API surface — the underscore marks it renderer-internal.
261
+ */
262
+ __noteRender(): void;
263
+ /**
264
+ * Internal: the DOM renderer calls this once per actual node rebuild (the
265
+ * changed-block branch) when an `onRenderMetrics` hook is wired, so
266
+ * `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
267
+ */
268
+ __noteRebuild(): void;
269
+ getMetrics(): {
270
+ bytes: number;
271
+ patches: number;
272
+ meanParseMicros: number;
273
+ totalParseMs: number;
274
+ throughputKBs: number;
275
+ committedBlocks: number;
276
+ activeBlocks: number;
277
+ lastPatchAgoMs: number;
278
+ retainedBytes: number;
279
+ wasmMemoryBytes: number;
280
+ renderCount: number;
281
+ rebuildCount: number;
282
+ };
283
+ /**
284
+ * A heading outline of the current snapshot (committed + active), in document
285
+ * order — for a table of contents. Works mid-stream; entries appear as their
286
+ * headings stream in. The `id` is stable, so a built ToC won't re-key.
287
+ */
288
+ outline(): OutlineEntry[];
289
+ /**
290
+ * The rendered document as plain text — tags stripped, entities decoded,
291
+ * blocks separated by blank lines. Derived from the rendered HTML (the source
292
+ * markdown is parsed away in WASM and not retained client-side), so it is a
293
+ * readable approximation for search indexing / summaries, not a round-trip of
294
+ * the original source.
295
+ */
296
+ toPlaintext(): string;
297
+ private onMessage;
298
+ /**
299
+ * Notify subscribers of a new snapshot.
300
+ *
301
+ * With `coalesce` off (default) this is fully synchronous, exactly as before.
302
+ * With it on and `requestAnimationFrame` available, a normal emit only
303
+ * *schedules* a single per-frame flush — repeated intra-frame emits collapse
304
+ * into one notify. `sync` forces an immediate flush (stream completion / reset)
305
+ * and cancels any frame already pending so the snapshot is delivered once.
306
+ */
307
+ private emit;
308
+ private flushNow;
309
+ private cancelFrame;
310
+ }
311
+ export {};