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 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.7.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. The core's HTML is well-formed and escapes `>` inside
37
- * attributes, so the simple tag regex is safe here. `&amp;` decodes last so
38
- * `&amp;lt;` `&lt;`, not `<`. */
36
+ * collapse whitespace. INVARIANT: the simple `<[^>]*>` strip is only safe
37
+ * because every input here is HTML the Rust core produced via escape_html /
38
+ * escape_attr which escape `>` inside attribute values, so no `>` ever
39
+ * appears except as a real tag close. This must NOT be fed externally-authored
40
+ * HTML. `&amp;` decodes last so `&amp;lt;` → `&lt;`, not `<`. */
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 existing worker is busy;
158
- // otherwise attach to the least-loaded existing worker.
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
- if (this.workers.length < this.cap && this.workers.every((w) => w.streamCount > 0)) {
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
- return this.workers.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
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
- // doomed worker stays in the pool: a later stream that pick()s it rejects
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) w.reject(err);
199
- for (const sid of pw.streamIds) this.handlers.get(sid)?.(msg);
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.handlers.get(msg.streamId)?.(msg);
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
@@ -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.7.0",
5
+ "version": "0.8.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",