flux-md 0.7.0 → 0.9.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 +62 -0
- package/README.md +56 -14
- package/package.json +1 -1
- package/src/client.ts +150 -12
- package/src/element.ts +5 -0
- package/src/react.tsx +120 -4
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,68 @@ 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.9.0 — 2026-05-29
|
|
8
|
+
|
|
9
|
+
Kills the React streaming boilerplate. The common case — render an LLM stream —
|
|
10
|
+
goes from ~17 lines of hand-rolled lifecycle to one:
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
<FluxMarkdown stream={stream} />
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`stream` prop on React `<FluxMarkdown>`** — pass an `AsyncIterable<string>`
|
|
19
|
+
(SSE deltas), a `Response`, or a `ReadableStream<Uint8Array>` and the
|
|
20
|
+
component owns an internal client, pipes the stream, supersedes it on change,
|
|
21
|
+
and destroys it on unmount. The `client` prop is unchanged (now optional);
|
|
22
|
+
passing a `client` keeps the existing caller-owned behavior.
|
|
23
|
+
- **`useFluxStream(stream, options?)` hook (React)** — same lifecycle, returns
|
|
24
|
+
the owned `FluxClient` (so you can read `outline()` / `getMetrics()` or pass it
|
|
25
|
+
to `<FluxMarkdown client={…} />`).
|
|
26
|
+
- **`pipeFrom` now also accepts an `AsyncIterable<string>`** and an optional
|
|
27
|
+
`{ signal }` — the signal is checked every iteration, so an aborted stream
|
|
28
|
+
appends no further chunks and is **not** finalized (and a byte reader is
|
|
29
|
+
`cancel()`'d). Existing `pipeFrom(Response | ReadableStream)` calls are
|
|
30
|
+
unchanged.
|
|
31
|
+
|
|
32
|
+
### Notes
|
|
33
|
+
|
|
34
|
+
- A stream is single-use, so React StrictMode's dev-only double-mount may
|
|
35
|
+
truncate it in development; production mounts once and is unaffected (the
|
|
36
|
+
prior manual `useEffect` form had the same caveat).
|
|
37
|
+
- Rules of Hooks are respected — `<FluxMarkdown>` dispatches to one of two
|
|
38
|
+
sibling components, never a conditional hook.
|
|
39
|
+
|
|
40
|
+
## 0.8.0 — 2026-05-29
|
|
41
|
+
|
|
42
|
+
A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
|
|
43
|
+
in the worker pool and added two small, streaming-native conveniences.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **`FluxClient.pipeFrom(src)`** — hand it a `Response` or a
|
|
48
|
+
`ReadableStream<Uint8Array>` and it reads the body, `append()`s each decoded
|
|
49
|
+
chunk, and `finalize()`s. The LLM-native one-liner:
|
|
50
|
+
`await client.pipeFrom(await fetch("/api/chat"))`.
|
|
51
|
+
- **`onBlock` option** — `new FluxClient({ onBlock })` fires once per block as it
|
|
52
|
+
commits (document order), for side effects like lazily highlighting a finished
|
|
53
|
+
code block or analytics. Committed blocks never re-fire.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **Worker pool: a throwing stream handler no longer breaks sibling streams.** A
|
|
58
|
+
user `onError` (or any handler) that threw could abort the fatal-error fan-out
|
|
59
|
+
mid-loop and escape the worker message listener; dispatch is now isolated.
|
|
60
|
+
- **Worker pool: a fatally-failed worker is no longer re-assigned.** `pick()`
|
|
61
|
+
skipped the `failed` flag, so after a WASM-init failure a new stream could be
|
|
62
|
+
routed onto the dead worker and hang (a client that didn't `await whenReady()`
|
|
63
|
+
had no safety net). Failed workers are now excluded from selection.
|
|
64
|
+
- **`<flux-markdown>`: manual `append()`/`finalize()` supersede an in-flight
|
|
65
|
+
`src` fetch** (mirroring `reset()`), so mixing the two can't interleave.
|
|
66
|
+
- Hardened the CI/publish tarball check (explicit failure if `npm pack` yields
|
|
67
|
+
no tarball) and documented the `htmlToText` core-HTML-only invariant.
|
|
68
|
+
|
|
7
69
|
## 0.7.0 — 2026-05-29
|
|
8
70
|
|
|
9
71
|
DX, robustness, and accessibility round — the streaming core (perf, CommonMark
|
package/README.md
CHANGED
|
@@ -52,33 +52,61 @@ for await (const delta of streamFromAi()) {
|
|
|
52
52
|
client.finalize();
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
In React
|
|
55
|
+
In React — pass the stream straight to `<FluxMarkdown>`. It owns the client,
|
|
56
|
+
pipes the stream, supersedes it if it changes, and cleans up on unmount:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { FluxMarkdown } from "flux-md/react";
|
|
60
|
+
|
|
61
|
+
export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
|
|
62
|
+
return <FluxMarkdown stream={stream} />;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`stream` accepts an `AsyncIterable<string>` (e.g. SSE deltas), a `Response`, or
|
|
67
|
+
a `ReadableStream<Uint8Array>` — so `<FluxMarkdown stream={await fetch("/api/chat")} />`
|
|
68
|
+
works too.
|
|
69
|
+
|
|
70
|
+
Need the client handle (for `outline()` / `getMetrics()` / a shared client)? Use
|
|
71
|
+
the `useFluxStream` hook — same lifecycle, returns the owned client:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import { FluxMarkdown, useFluxStream } from "flux-md/react";
|
|
75
|
+
|
|
76
|
+
export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
|
|
77
|
+
const client = useFluxStream(stream);
|
|
78
|
+
return <FluxMarkdown client={client} />;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
<details>
|
|
83
|
+
<summary>Full manual control (caller-owned client)</summary>
|
|
84
|
+
|
|
85
|
+
When you want to drive the stream yourself, pass a `client` you own — the
|
|
86
|
+
component never destroys it:
|
|
56
87
|
|
|
57
88
|
```tsx
|
|
58
89
|
import { useEffect, useState } from "react";
|
|
59
90
|
import { FluxClient, FluxMarkdown } from "flux-md";
|
|
60
91
|
|
|
61
92
|
export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
|
|
62
|
-
// One client per component instance. Destroy on unmount, not on stream change.
|
|
63
93
|
const [client] = useState(() => new FluxClient());
|
|
64
94
|
useEffect(() => () => client.destroy(), [client]);
|
|
65
|
-
|
|
66
95
|
useEffect(() => {
|
|
67
|
-
|
|
68
|
-
(
|
|
69
|
-
|
|
70
|
-
if (cancelled) return; // stream changed / unmounted mid-flight
|
|
71
|
-
client.append(chunk);
|
|
72
|
-
}
|
|
73
|
-
if (!cancelled) client.finalize();
|
|
74
|
-
})();
|
|
75
|
-
return () => { cancelled = true; };
|
|
96
|
+
const ac = new AbortController();
|
|
97
|
+
client.pipeFrom(stream, { signal: ac.signal }); // pipeFrom also accepts AsyncIterable
|
|
98
|
+
return () => ac.abort();
|
|
76
99
|
}, [client, stream]);
|
|
77
|
-
|
|
78
100
|
return <FluxMarkdown client={client} />;
|
|
79
101
|
}
|
|
80
102
|
```
|
|
81
103
|
|
|
104
|
+
</details>
|
|
105
|
+
|
|
106
|
+
> **StrictMode note:** a stream (SSE generator / `Response`) can be consumed only
|
|
107
|
+
> once, so React StrictMode's dev-only double-mount may truncate it in
|
|
108
|
+
> development. Production mounts once and is unaffected.
|
|
109
|
+
|
|
82
110
|
Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.
|
|
83
111
|
|
|
84
112
|
## Framework bindings
|
|
@@ -262,8 +290,13 @@ class FluxClient {
|
|
|
262
290
|
pool?: FluxPool;
|
|
263
291
|
config?: ParserConfig;
|
|
264
292
|
onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
|
|
293
|
+
onBlock?: (block: Block) => void; // fires once per block as it commits
|
|
265
294
|
});
|
|
266
295
|
append(chunk: string): void; // queue text for parsing
|
|
296
|
+
pipeFrom( // read → append → finalize
|
|
297
|
+
src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
|
|
298
|
+
opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
|
|
299
|
+
): Promise<void>;
|
|
267
300
|
finalize(): void; // mark stream complete
|
|
268
301
|
reset(): void; // wipe and reuse
|
|
269
302
|
destroy(): void; // free this stream's parser
|
|
@@ -277,9 +310,18 @@ class FluxClient {
|
|
|
277
310
|
}
|
|
278
311
|
```
|
|
279
312
|
|
|
313
|
+
`pipeFrom` is the LLM-native shortcut — hand it a `fetch` response and it
|
|
314
|
+
reads, appends, and finalizes for you:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
const client = new FluxClient();
|
|
318
|
+
await client.pipeFrom(await fetch("/api/chat")); // streams the body in, then finalizes
|
|
319
|
+
```
|
|
320
|
+
|
|
280
321
|
Pass `onError` to be notified of worker/parse errors and a fatal WASM-init
|
|
281
322
|
failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
|
|
282
|
-
load failure surfaces as a rejected `whenReady()`.
|
|
323
|
+
load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
|
|
324
|
+
effect each time a block commits (e.g. lazy-highlight a finished code block).
|
|
283
325
|
|
|
284
326
|
#### Per-stream config
|
|
285
327
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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"],
|
package/src/client.ts
CHANGED
|
@@ -33,9 +33,11 @@ export interface OutlineEntry {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Strip tags (→ space) and decode the small entity set the core emits, then
|
|
36
|
-
* collapse whitespace.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* collapse whitespace. INVARIANT: the simple `<[^>]*>` strip is only safe
|
|
37
|
+
* because every input here is HTML the Rust core produced via escape_html /
|
|
38
|
+
* escape_attr — which escape `>` inside attribute values, so no `>` ever
|
|
39
|
+
* appears except as a real tag close. This must NOT be fed externally-authored
|
|
40
|
+
* HTML. `&` decodes last so `&lt;` → `<`, not `<`. */
|
|
39
41
|
function htmlToText(html: string): string {
|
|
40
42
|
return html
|
|
41
43
|
.replace(/<[^>]*>/g, " ")
|
|
@@ -126,6 +128,18 @@ export class FluxPool {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/** Inverse of {@link release}: re-register a stream's handler so it receives
|
|
132
|
+
* patches again. For React StrictMode's dev double-mount, which destroys a
|
|
133
|
+
* client on the simulated unmount and remounts the SAME instance. The worker
|
|
134
|
+
* lazily recreates the disposed parser on the next append. */
|
|
135
|
+
reattach(streamId: number, pw: PoolWorker, handler: (msg: FromWorker) => void): void {
|
|
136
|
+
if (!this.handlers.has(streamId)) {
|
|
137
|
+
pw.streamCount++;
|
|
138
|
+
pw.streamIds.add(streamId);
|
|
139
|
+
}
|
|
140
|
+
this.handlers.set(streamId, handler);
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
send(pw: PoolWorker, msg: ToWorker): void {
|
|
130
144
|
pw.worker.postMessage(msg);
|
|
131
145
|
}
|
|
@@ -154,13 +168,17 @@ export class FluxPool {
|
|
|
154
168
|
return this.workers.length;
|
|
155
169
|
}
|
|
156
170
|
|
|
157
|
-
// Create a new worker while under cap and every
|
|
158
|
-
//
|
|
171
|
+
// Create a new worker while under cap and every live worker is busy; otherwise
|
|
172
|
+
// attach to the least-loaded LIVE worker. A fatally-failed worker is never
|
|
173
|
+
// handed out (a stream on it would post into a dead worker and hang) — it is
|
|
174
|
+
// retained only to reject outstanding whenWorkerReady waiters.
|
|
159
175
|
private pick(): PoolWorker {
|
|
160
|
-
|
|
176
|
+
const live = this.workers.filter((w) => !w.failed);
|
|
177
|
+
if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
|
|
161
178
|
return this.create();
|
|
162
179
|
}
|
|
163
|
-
|
|
180
|
+
if (live.length === 0) return this.create();
|
|
181
|
+
return live.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
|
|
164
182
|
}
|
|
165
183
|
|
|
166
184
|
private create(): PoolWorker {
|
|
@@ -189,17 +207,34 @@ export class FluxPool {
|
|
|
189
207
|
// A fatal (WASM-init) failure dooms every stream on this worker. Reject
|
|
190
208
|
// anyone awaiting readiness, then notify each live stream's client so its
|
|
191
209
|
// onError fires — the message carries no real streamId to route by. The
|
|
192
|
-
//
|
|
193
|
-
// immediately via pw.failed (no auto-recovery — fine for a load failure).
|
|
210
|
+
// worker is kept only to reject those waiters; pick() never reuses it.
|
|
194
211
|
const err = new Error(msg.message);
|
|
195
212
|
pw.failed = err;
|
|
196
213
|
const waiters = pw.readyWaiters;
|
|
197
214
|
pw.readyWaiters = [];
|
|
198
|
-
for (const w of waiters)
|
|
199
|
-
|
|
215
|
+
for (const w of waiters) {
|
|
216
|
+
try {
|
|
217
|
+
w.reject(err);
|
|
218
|
+
} catch {
|
|
219
|
+
/* a waiter's rejection handler is the caller's problem, not ours */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const sid of pw.streamIds) this.dispatch(sid, msg);
|
|
200
223
|
return;
|
|
201
224
|
}
|
|
202
|
-
this.
|
|
225
|
+
this.dispatch(msg.streamId, msg);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Route a message to a stream's handler, isolating a throwing client callback
|
|
229
|
+
// (e.g. a user-supplied onError) so it can neither break the worker message
|
|
230
|
+
// loop nor starve sibling streams sharing this worker.
|
|
231
|
+
private dispatch(streamId: number, msg: FromWorker): void {
|
|
232
|
+
try {
|
|
233
|
+
this.handlers.get(streamId)?.(msg);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// eslint-disable-next-line no-console
|
|
236
|
+
console.error("flux: stream message handler threw", e);
|
|
237
|
+
}
|
|
203
238
|
}
|
|
204
239
|
}
|
|
205
240
|
|
|
@@ -249,6 +284,8 @@ export class FluxClient {
|
|
|
249
284
|
private listeners = new Set<() => void>();
|
|
250
285
|
private store: BlockStore = emptyBlockStore();
|
|
251
286
|
private onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
287
|
+
private onBlock?: (block: Block) => void;
|
|
288
|
+
private attached = true;
|
|
252
289
|
|
|
253
290
|
// Perf
|
|
254
291
|
private appendedBytes = 0;
|
|
@@ -267,17 +304,23 @@ export class FluxClient {
|
|
|
267
304
|
* @param options.onError invoked on a worker/parse error or a fatal WASM-init
|
|
268
305
|
* failure (`fatal: true`). Without it, errors are only `console.error`d and
|
|
269
306
|
* a load failure surfaces solely as a rejected {@link FluxClient.whenReady}.
|
|
307
|
+
* @param options.onBlock invoked once per block as it commits (in document
|
|
308
|
+
* order, after the store updates) — for side effects like lazily
|
|
309
|
+
* highlighting a finished code block or analytics. A committed block never
|
|
310
|
+
* re-fires; the streaming tail does not (subscribe for live tail updates).
|
|
270
311
|
*/
|
|
271
312
|
constructor(
|
|
272
313
|
options: {
|
|
273
314
|
pool?: FluxPool;
|
|
274
315
|
config?: ParserConfig;
|
|
275
316
|
onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
317
|
+
onBlock?: (block: Block) => void;
|
|
276
318
|
} = {},
|
|
277
319
|
) {
|
|
278
320
|
this.pool = options.pool ?? getDefaultPool();
|
|
279
321
|
this.config = options.config;
|
|
280
322
|
this.onError = options.onError;
|
|
323
|
+
this.onBlock = options.onBlock;
|
|
281
324
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
282
325
|
this.streamId = streamId;
|
|
283
326
|
this.pw = pw;
|
|
@@ -309,6 +352,77 @@ export class FluxClient {
|
|
|
309
352
|
this.pool.send(this.pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
|
|
310
353
|
}
|
|
311
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Pipe a source straight in: read it to completion, `append()` each chunk,
|
|
357
|
+
* then `finalize()`. The LLM-native path — e.g.
|
|
358
|
+
* `await client.pipeFrom(await fetch("/api/chat"))`. Accepts:
|
|
359
|
+
* - a `Response` or its `ReadableStream<Uint8Array>` body (bytes; decoded
|
|
360
|
+
* with `TextDecoder({ stream: true })` so a multibyte sequence straddling
|
|
361
|
+
* a chunk boundary carries into the next read), or
|
|
362
|
+
* - an `AsyncIterable<string>` (e.g. an SSE delta generator) — string chunks
|
|
363
|
+
* appended verbatim.
|
|
364
|
+
*
|
|
365
|
+
* Pass `opts.signal` to supersede/cancel: the signal is checked on every
|
|
366
|
+
* iteration, so once aborted no further chunk is appended and **finalize is
|
|
367
|
+
* skipped** (a superseded stream must not finalize). For a byte source the
|
|
368
|
+
* reader is also `cancel()`'d to tear down the upstream. Resolves once
|
|
369
|
+
* finalized (or cleanly on abort); rejects if the source itself errors.
|
|
370
|
+
* Browser-only for byte sources (uses `TextDecoder`).
|
|
371
|
+
*/
|
|
372
|
+
async pipeFrom(
|
|
373
|
+
source: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
|
|
374
|
+
opts?: { signal?: AbortSignal },
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
const signal = opts?.signal;
|
|
377
|
+
|
|
378
|
+
if (signal?.aborted) return; // already superseded before we started
|
|
379
|
+
|
|
380
|
+
// AsyncIterable<string> (SSE deltas, generators). Detected by elimination:
|
|
381
|
+
// a ReadableStream has `getReader`, a Response has `body` — neither here.
|
|
382
|
+
if (!("getReader" in source) && !("body" in source)) {
|
|
383
|
+
for await (const chunk of source as AsyncIterable<string>) {
|
|
384
|
+
if (signal?.aborted) return; // superseded/unmounted: drop late chunks, no finalize
|
|
385
|
+
this.append(chunk);
|
|
386
|
+
}
|
|
387
|
+
if (!signal?.aborted) this.finalize();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Byte source: a Response (use its body) or a ReadableStream directly.
|
|
392
|
+
const body = "body" in source ? source.body : source;
|
|
393
|
+
if (!body) {
|
|
394
|
+
// An empty Response body (e.g. 204) is a completed, empty stream.
|
|
395
|
+
this.finalize();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const reader = body.getReader();
|
|
399
|
+
// A pending read() can't observe `aborted` until the next chunk; cancel()
|
|
400
|
+
// on abort tears down the upstream and resolves the pending read so the
|
|
401
|
+
// loop's post-read check fires and bails without finalizing.
|
|
402
|
+
const onAbort = () => {
|
|
403
|
+
reader.cancel().catch(() => {});
|
|
404
|
+
};
|
|
405
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
406
|
+
const decoder = new TextDecoder();
|
|
407
|
+
try {
|
|
408
|
+
for (;;) {
|
|
409
|
+
const { done, value } = await reader.read();
|
|
410
|
+
if (signal?.aborted) return; // superseded: no finalize (cancel already fired)
|
|
411
|
+
if (done) break;
|
|
412
|
+
if (value) this.append(decoder.decode(value, { stream: true }));
|
|
413
|
+
}
|
|
414
|
+
this.append(decoder.decode()); // flush any trailing partial sequence
|
|
415
|
+
this.finalize();
|
|
416
|
+
} finally {
|
|
417
|
+
signal?.removeEventListener("abort", onAbort);
|
|
418
|
+
try {
|
|
419
|
+
reader.releaseLock();
|
|
420
|
+
} catch {
|
|
421
|
+
/* already released (e.g. by cancel) */
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
312
426
|
reset() {
|
|
313
427
|
this.store = emptyBlockStore();
|
|
314
428
|
this.appendedBytes = 0;
|
|
@@ -324,9 +438,27 @@ export class FluxClient {
|
|
|
324
438
|
}
|
|
325
439
|
|
|
326
440
|
destroy() {
|
|
441
|
+
if (!this.attached) return; // idempotent
|
|
327
442
|
// Free this stream's parser; the shared worker stays warm for siblings.
|
|
328
443
|
this.pool.release(this.streamId, this.pw);
|
|
329
444
|
this.listeners.clear();
|
|
445
|
+
this.attached = false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Re-register with the pool after {@link destroy} so the client receives
|
|
450
|
+
* patches again. Needed only for React StrictMode's dev double-mount, where
|
|
451
|
+
* the renderer destroys on the simulated unmount then remounts the SAME
|
|
452
|
+
* client instance; apps don't normally call this. No-op if still attached.
|
|
453
|
+
*/
|
|
454
|
+
reattach() {
|
|
455
|
+
if (this.attached) return;
|
|
456
|
+
this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
|
|
457
|
+
this.attached = true;
|
|
458
|
+
// The worker discarded this stream's config on `dispose` (unlike `reset`,
|
|
459
|
+
// which keeps it), so re-send it on the next message — otherwise the parser
|
|
460
|
+
// would be rebuilt with library defaults (gfmMath / componentTags / … lost).
|
|
461
|
+
this.configSent = false;
|
|
330
462
|
}
|
|
331
463
|
|
|
332
464
|
subscribe = (fn: () => void) => {
|
|
@@ -397,6 +529,12 @@ export class FluxClient {
|
|
|
397
529
|
this.patchCount += 1;
|
|
398
530
|
this.lastPatchMs = performance.now();
|
|
399
531
|
this.emit();
|
|
532
|
+
// After subscribers see the new snapshot, fire the per-block hook for
|
|
533
|
+
// anything that just committed (document order). A throw here is
|
|
534
|
+
// isolated by the pool's dispatch boundary and won't skip emit().
|
|
535
|
+
if (this.onBlock) {
|
|
536
|
+
for (const b of msg.patch.newly_committed) this.onBlock(b);
|
|
537
|
+
}
|
|
400
538
|
break;
|
|
401
539
|
case "error":
|
|
402
540
|
if (this.onError) {
|
package/src/element.ts
CHANGED
|
@@ -105,12 +105,17 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
105
105
|
// --- Self-owned-client methods -------------------------------------------
|
|
106
106
|
|
|
107
107
|
append(chunk: string): void {
|
|
108
|
+
// Manual drive supersedes any in-flight `src` fetch (mixing the two is out
|
|
109
|
+
// of contract; this makes the manual stream win predictably instead of
|
|
110
|
+
// interleaving a late fetch chunk into it).
|
|
111
|
+
this.#cancelSrcStream();
|
|
108
112
|
this.#ensureClient();
|
|
109
113
|
this.#client!.append(chunk);
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
finalize(): void {
|
|
113
117
|
// Only meaningful for a self-owned stream; a no-op if no client yet.
|
|
118
|
+
this.#cancelSrcStream();
|
|
114
119
|
this.#client?.finalize();
|
|
115
120
|
}
|
|
116
121
|
|
package/src/react.tsx
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createElement,
|
|
3
|
+
memo,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type CSSProperties,
|
|
10
|
+
} from "react";
|
|
2
11
|
import type { Block, BlockComponentProps, Components } from "./types";
|
|
3
|
-
import
|
|
12
|
+
import { FluxClient } from "./client";
|
|
13
|
+
import type { ParserConfig } from "./types-core";
|
|
4
14
|
import { CodeBlock } from "./renderers/CodeBlock";
|
|
5
15
|
import { MathBlock } from "./renderers/Math";
|
|
6
16
|
import { Mermaid } from "./renderers/Mermaid";
|
|
@@ -48,7 +58,23 @@ import { htmlToReact } from "./html-to-react";
|
|
|
48
58
|
*/
|
|
49
59
|
|
|
50
60
|
interface FluxMarkdownProps {
|
|
51
|
-
|
|
61
|
+
/**
|
|
62
|
+
* A caller-owned client (you drive `append`/`finalize` and own its lifecycle —
|
|
63
|
+
* the component never destroys it). Mutually exclusive with `stream`; if both
|
|
64
|
+
* are given, `client` wins (a dev warning fires).
|
|
65
|
+
*/
|
|
66
|
+
client?: FluxClient;
|
|
67
|
+
/**
|
|
68
|
+
* A stream to render directly — the 1-line common case. Pass a `Response`, a
|
|
69
|
+
* `ReadableStream<Uint8Array>`, or an `AsyncIterable<string>` (e.g. SSE
|
|
70
|
+
* deltas) and the component owns an internal client, pipes the stream, and
|
|
71
|
+
* destroys it on unmount. A new `stream` identity supersedes the old.
|
|
72
|
+
*/
|
|
73
|
+
stream?: AsyncIterable<string> | ReadableStream<Uint8Array> | Response;
|
|
74
|
+
/** Parser config for the internally-created client (stream mode only). */
|
|
75
|
+
streamConfig?: ParserConfig;
|
|
76
|
+
/** Called if piping the `stream` rejects (the source errored). Not the worker error channel. */
|
|
77
|
+
onStreamError?: (err: Error) => void;
|
|
52
78
|
components?: Components;
|
|
53
79
|
/**
|
|
54
80
|
* Skip layout/paint for off-screen blocks via CSS `content-visibility: auto`
|
|
@@ -77,7 +103,15 @@ interface FluxMarkdownProps {
|
|
|
77
103
|
sanitize?: (html: string) => string;
|
|
78
104
|
}
|
|
79
105
|
|
|
80
|
-
|
|
106
|
+
// The original render path: subscribe to a (required, caller- or hook-owned)
|
|
107
|
+
// client and render its blocks. NEVER creates or destroys a client.
|
|
108
|
+
function FluxMarkdownFromClient({
|
|
109
|
+
client,
|
|
110
|
+
components,
|
|
111
|
+
virtualize,
|
|
112
|
+
stickToBottom,
|
|
113
|
+
sanitize,
|
|
114
|
+
}: FluxMarkdownProps & { client: FluxClient }) {
|
|
81
115
|
const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
|
|
82
116
|
// Normalize "no overrides" to a stable `undefined` so memo comparisons and
|
|
83
117
|
// the fast path don't churn on an empty object identity.
|
|
@@ -92,6 +126,88 @@ function FluxMarkdownImpl({ client, components, virtualize, stickToBottom, sanit
|
|
|
92
126
|
);
|
|
93
127
|
}
|
|
94
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Own a {@link FluxClient} for the lifetime of a component and drive it from a
|
|
131
|
+
* `stream` (a `Response`, `ReadableStream<Uint8Array>`, or
|
|
132
|
+
* `AsyncIterable<string>`). Returns the client (read `outline()` / `getMetrics()`
|
|
133
|
+
* off it, or pass it to `<FluxMarkdown client={…} />`). The client is created
|
|
134
|
+
* once and destroyed on unmount; a new `stream` identity supersedes the old
|
|
135
|
+
* (the prior pipe is aborted, the parser is reset, the new stream is piped).
|
|
136
|
+
*
|
|
137
|
+
* Caveat (matches the manual `useEffect` form): a single-use stream — a
|
|
138
|
+
* `Response`/`ReadableStream`, or an async generator — can only be consumed
|
|
139
|
+
* once, so React **StrictMode**'s dev-only double-mount may truncate it in
|
|
140
|
+
* development. Production mounts once and is unaffected. If you need dev-exact
|
|
141
|
+
* streaming, drive a caller-owned client manually.
|
|
142
|
+
*/
|
|
143
|
+
export function useFluxStream(
|
|
144
|
+
stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined,
|
|
145
|
+
options?: { config?: ParserConfig; onError?: (err: Error) => void },
|
|
146
|
+
): FluxClient {
|
|
147
|
+
// One client per hook instance. (React StrictMode double-invokes this
|
|
148
|
+
// initializer in DEV, constructing a throwaway second client whose worker
|
|
149
|
+
// slot isn't reclaimed — a minor dev-only artifact; production runs it once.
|
|
150
|
+
// The committed client is what's used, and its lifecycle below is correct.)
|
|
151
|
+
const [client] = useState(() => new FluxClient({ config: options?.config }));
|
|
152
|
+
// Read onError through a ref so its identity never re-subscribes the stream.
|
|
153
|
+
const onErrorRef = useRef(options?.onError);
|
|
154
|
+
onErrorRef.current = options?.onError;
|
|
155
|
+
// Track the last stream so we reset() only on a genuine source change — never
|
|
156
|
+
// on a StrictMode replay of the same stream (which would discard its head).
|
|
157
|
+
const prevStream = useRef<typeof stream>(undefined);
|
|
158
|
+
|
|
159
|
+
// Own the client's pool attachment. On (re)mount, reattach (StrictMode's
|
|
160
|
+
// dev double-mount destroys on the simulated unmount, then remounts the SAME
|
|
161
|
+
// instance — without reattach its patches would be dropped and it'd render
|
|
162
|
+
// blank); destroy on real unmount.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
client.reattach();
|
|
165
|
+
return () => client.destroy();
|
|
166
|
+
}, [client]);
|
|
167
|
+
|
|
168
|
+
// Consume the current stream; supersede (abort, no finalize) on change/unmount.
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (stream == null) return;
|
|
171
|
+
const ac = new AbortController();
|
|
172
|
+
if (prevStream.current !== undefined && prevStream.current !== stream) {
|
|
173
|
+
client.reset(); // a different stream replaced a prior one
|
|
174
|
+
}
|
|
175
|
+
prevStream.current = stream;
|
|
176
|
+
client.pipeFrom(stream, { signal: ac.signal }).catch((e) => {
|
|
177
|
+
if (!ac.signal.aborted) {
|
|
178
|
+
onErrorRef.current?.(e instanceof Error ? e : new Error(String(e)));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return () => ac.abort();
|
|
182
|
+
}, [stream, client]);
|
|
183
|
+
|
|
184
|
+
return client;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Stream mode: own a client via the hook, then render the normal client path.
|
|
188
|
+
function FluxMarkdownFromStream(props: FluxMarkdownProps) {
|
|
189
|
+
const client = useFluxStream(props.stream, {
|
|
190
|
+
config: props.streamConfig,
|
|
191
|
+
onError: props.onStreamError,
|
|
192
|
+
});
|
|
193
|
+
return <FluxMarkdownFromClient {...props} client={client} />;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Dispatch by rendering one of two SIBLING components (never a hook in a branch,
|
|
197
|
+
// which would violate the Rules of Hooks): `stream` mode owns a client, `client`
|
|
198
|
+
// mode uses the caller's. `memo` skips re-render when props are unchanged. If
|
|
199
|
+
// both are given `client` wins (it owns the lifecycle); passing neither is a
|
|
200
|
+
// usage error and throws (rather than crashing cryptically downstream).
|
|
201
|
+
function FluxMarkdownImpl(props: FluxMarkdownProps) {
|
|
202
|
+
if (props.stream != null && props.client == null) {
|
|
203
|
+
return <FluxMarkdownFromStream {...props} />;
|
|
204
|
+
}
|
|
205
|
+
if (props.client == null) {
|
|
206
|
+
throw new Error("<FluxMarkdown>: pass either a `client` or a `stream` prop.");
|
|
207
|
+
}
|
|
208
|
+
return <FluxMarkdownFromClient {...(props as FluxMarkdownProps & { client: FluxClient })} />;
|
|
209
|
+
}
|
|
210
|
+
|
|
95
211
|
export const FluxMarkdown = memo(FluxMarkdownImpl);
|
|
96
212
|
|
|
97
213
|
function decodeEntities(s: string): string {
|
|
Binary file
|
package/src/wasm/package.json
CHANGED