flux-md 0.7.0 → 0.8.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 +29 -0
- package/README.md +12 -1
- package/package.json +1 -1
- package/src/client.ts +79 -12
- package/src/element.ts +5 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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.8.0 — 2026-05-29
|
|
8
|
+
|
|
9
|
+
A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
|
|
10
|
+
in the worker pool and added two small, streaming-native conveniences.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`FluxClient.pipeFrom(src)`** — hand it a `Response` or a
|
|
15
|
+
`ReadableStream<Uint8Array>` and it reads the body, `append()`s each decoded
|
|
16
|
+
chunk, and `finalize()`s. The LLM-native one-liner:
|
|
17
|
+
`await client.pipeFrom(await fetch("/api/chat"))`.
|
|
18
|
+
- **`onBlock` option** — `new FluxClient({ onBlock })` fires once per block as it
|
|
19
|
+
commits (document order), for side effects like lazily highlighting a finished
|
|
20
|
+
code block or analytics. Committed blocks never re-fire.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Worker pool: a throwing stream handler no longer breaks sibling streams.** A
|
|
25
|
+
user `onError` (or any handler) that threw could abort the fatal-error fan-out
|
|
26
|
+
mid-loop and escape the worker message listener; dispatch is now isolated.
|
|
27
|
+
- **Worker pool: a fatally-failed worker is no longer re-assigned.** `pick()`
|
|
28
|
+
skipped the `failed` flag, so after a WASM-init failure a new stream could be
|
|
29
|
+
routed onto the dead worker and hang (a client that didn't `await whenReady()`
|
|
30
|
+
had no safety net). Failed workers are now excluded from selection.
|
|
31
|
+
- **`<flux-markdown>`: manual `append()`/`finalize()` supersede an in-flight
|
|
32
|
+
`src` fetch** (mirroring `reset()`), so mixing the two can't interleave.
|
|
33
|
+
- Hardened the CI/publish tarball check (explicit failure if `npm pack` yields
|
|
34
|
+
no tarball) and documented the `htmlToText` core-HTML-only invariant.
|
|
35
|
+
|
|
7
36
|
## 0.7.0 — 2026-05-29
|
|
8
37
|
|
|
9
38
|
DX, robustness, and accessibility round — the streaming core (perf, CommonMark
|
package/README.md
CHANGED
|
@@ -262,8 +262,10 @@ class FluxClient {
|
|
|
262
262
|
pool?: FluxPool;
|
|
263
263
|
config?: ParserConfig;
|
|
264
264
|
onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
|
|
265
|
+
onBlock?: (block: Block) => void; // fires once per block as it commits
|
|
265
266
|
});
|
|
266
267
|
append(chunk: string): void; // queue text for parsing
|
|
268
|
+
pipeFrom(src: ReadableStream<Uint8Array> | Response): Promise<void>; // read → append → finalize
|
|
267
269
|
finalize(): void; // mark stream complete
|
|
268
270
|
reset(): void; // wipe and reuse
|
|
269
271
|
destroy(): void; // free this stream's parser
|
|
@@ -277,9 +279,18 @@ class FluxClient {
|
|
|
277
279
|
}
|
|
278
280
|
```
|
|
279
281
|
|
|
282
|
+
`pipeFrom` is the LLM-native shortcut — hand it a `fetch` response and it
|
|
283
|
+
reads, appends, and finalizes for you:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const client = new FluxClient();
|
|
287
|
+
await client.pipeFrom(await fetch("/api/chat")); // streams the body in, then finalizes
|
|
288
|
+
```
|
|
289
|
+
|
|
280
290
|
Pass `onError` to be notified of worker/parse errors and a fatal WASM-init
|
|
281
291
|
failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
|
|
282
|
-
load failure surfaces as a rejected `whenReady()`.
|
|
292
|
+
load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
|
|
293
|
+
effect each time a block commits (e.g. lazy-highlight a finished code block).
|
|
283
294
|
|
|
284
295
|
#### Per-stream config
|
|
285
296
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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, " ")
|
|
@@ -154,13 +156,17 @@ export class FluxPool {
|
|
|
154
156
|
return this.workers.length;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
// Create a new worker while under cap and every
|
|
158
|
-
//
|
|
159
|
+
// Create a new worker while under cap and every live worker is busy; otherwise
|
|
160
|
+
// attach to the least-loaded LIVE worker. A fatally-failed worker is never
|
|
161
|
+
// handed out (a stream on it would post into a dead worker and hang) — it is
|
|
162
|
+
// retained only to reject outstanding whenWorkerReady waiters.
|
|
159
163
|
private pick(): PoolWorker {
|
|
160
|
-
|
|
164
|
+
const live = this.workers.filter((w) => !w.failed);
|
|
165
|
+
if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
|
|
161
166
|
return this.create();
|
|
162
167
|
}
|
|
163
|
-
|
|
168
|
+
if (live.length === 0) return this.create();
|
|
169
|
+
return live.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
|
|
164
170
|
}
|
|
165
171
|
|
|
166
172
|
private create(): PoolWorker {
|
|
@@ -189,17 +195,34 @@ export class FluxPool {
|
|
|
189
195
|
// A fatal (WASM-init) failure dooms every stream on this worker. Reject
|
|
190
196
|
// anyone awaiting readiness, then notify each live stream's client so its
|
|
191
197
|
// 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).
|
|
198
|
+
// worker is kept only to reject those waiters; pick() never reuses it.
|
|
194
199
|
const err = new Error(msg.message);
|
|
195
200
|
pw.failed = err;
|
|
196
201
|
const waiters = pw.readyWaiters;
|
|
197
202
|
pw.readyWaiters = [];
|
|
198
|
-
for (const w of waiters)
|
|
199
|
-
|
|
203
|
+
for (const w of waiters) {
|
|
204
|
+
try {
|
|
205
|
+
w.reject(err);
|
|
206
|
+
} catch {
|
|
207
|
+
/* a waiter's rejection handler is the caller's problem, not ours */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const sid of pw.streamIds) this.dispatch(sid, msg);
|
|
200
211
|
return;
|
|
201
212
|
}
|
|
202
|
-
this.
|
|
213
|
+
this.dispatch(msg.streamId, msg);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Route a message to a stream's handler, isolating a throwing client callback
|
|
217
|
+
// (e.g. a user-supplied onError) so it can neither break the worker message
|
|
218
|
+
// loop nor starve sibling streams sharing this worker.
|
|
219
|
+
private dispatch(streamId: number, msg: FromWorker): void {
|
|
220
|
+
try {
|
|
221
|
+
this.handlers.get(streamId)?.(msg);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.error("flux: stream message handler threw", e);
|
|
225
|
+
}
|
|
203
226
|
}
|
|
204
227
|
}
|
|
205
228
|
|
|
@@ -249,6 +272,7 @@ export class FluxClient {
|
|
|
249
272
|
private listeners = new Set<() => void>();
|
|
250
273
|
private store: BlockStore = emptyBlockStore();
|
|
251
274
|
private onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
275
|
+
private onBlock?: (block: Block) => void;
|
|
252
276
|
|
|
253
277
|
// Perf
|
|
254
278
|
private appendedBytes = 0;
|
|
@@ -267,17 +291,23 @@ export class FluxClient {
|
|
|
267
291
|
* @param options.onError invoked on a worker/parse error or a fatal WASM-init
|
|
268
292
|
* failure (`fatal: true`). Without it, errors are only `console.error`d and
|
|
269
293
|
* a load failure surfaces solely as a rejected {@link FluxClient.whenReady}.
|
|
294
|
+
* @param options.onBlock invoked once per block as it commits (in document
|
|
295
|
+
* order, after the store updates) — for side effects like lazily
|
|
296
|
+
* highlighting a finished code block or analytics. A committed block never
|
|
297
|
+
* re-fires; the streaming tail does not (subscribe for live tail updates).
|
|
270
298
|
*/
|
|
271
299
|
constructor(
|
|
272
300
|
options: {
|
|
273
301
|
pool?: FluxPool;
|
|
274
302
|
config?: ParserConfig;
|
|
275
303
|
onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
304
|
+
onBlock?: (block: Block) => void;
|
|
276
305
|
} = {},
|
|
277
306
|
) {
|
|
278
307
|
this.pool = options.pool ?? getDefaultPool();
|
|
279
308
|
this.config = options.config;
|
|
280
309
|
this.onError = options.onError;
|
|
310
|
+
this.onBlock = options.onBlock;
|
|
281
311
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
282
312
|
this.streamId = streamId;
|
|
283
313
|
this.pw = pw;
|
|
@@ -309,6 +339,37 @@ export class FluxClient {
|
|
|
309
339
|
this.pool.send(this.pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
|
|
310
340
|
}
|
|
311
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Pipe a byte stream straight in: read it to completion, `append()` each
|
|
344
|
+
* decoded chunk, then `finalize()`. The LLM-native path — `await
|
|
345
|
+
* client.pipeFrom(await fetch("/api/chat"))` (pass a `Response` or its
|
|
346
|
+
* `ReadableStream` body). `TextDecoder({ stream: true })` carries a multibyte
|
|
347
|
+
* sequence that straddles a chunk boundary into the next read. Resolves once
|
|
348
|
+
* finalized; rejects (without finalizing) if the stream errors/aborts — abort
|
|
349
|
+
* the underlying fetch to cancel. Browser-only (uses `TextDecoder`).
|
|
350
|
+
*/
|
|
351
|
+
async pipeFrom(source: ReadableStream<Uint8Array> | Response): Promise<void> {
|
|
352
|
+
const body = "body" in source ? source.body : source;
|
|
353
|
+
if (!body) {
|
|
354
|
+
// An empty Response body (e.g. 204) is a completed, empty stream.
|
|
355
|
+
this.finalize();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const reader = body.getReader();
|
|
359
|
+
const decoder = new TextDecoder();
|
|
360
|
+
try {
|
|
361
|
+
for (;;) {
|
|
362
|
+
const { done, value } = await reader.read();
|
|
363
|
+
if (done) break;
|
|
364
|
+
if (value) this.append(decoder.decode(value, { stream: true }));
|
|
365
|
+
}
|
|
366
|
+
this.append(decoder.decode()); // flush any trailing partial sequence
|
|
367
|
+
this.finalize();
|
|
368
|
+
} finally {
|
|
369
|
+
reader.releaseLock();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
312
373
|
reset() {
|
|
313
374
|
this.store = emptyBlockStore();
|
|
314
375
|
this.appendedBytes = 0;
|
|
@@ -397,6 +458,12 @@ export class FluxClient {
|
|
|
397
458
|
this.patchCount += 1;
|
|
398
459
|
this.lastPatchMs = performance.now();
|
|
399
460
|
this.emit();
|
|
461
|
+
// After subscribers see the new snapshot, fire the per-block hook for
|
|
462
|
+
// anything that just committed (document order). A throw here is
|
|
463
|
+
// isolated by the pool's dispatch boundary and won't skip emit().
|
|
464
|
+
if (this.onBlock) {
|
|
465
|
+
for (const b of msg.patch.newly_committed) this.onBlock(b);
|
|
466
|
+
}
|
|
400
467
|
break;
|
|
401
468
|
case "error":
|
|
402
469
|
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
|
|
|
Binary file
|
package/src/wasm/package.json
CHANGED