flux-md 0.17.0 → 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.
package/README.md CHANGED
@@ -25,8 +25,10 @@ constructs Web Workers). For **server-side / static rendering of finished
25
25
  content** — SSR, React Server Components, build steps — use the worker-free,
26
26
  synchronous [`flux-md/server`](#server-side-rendering) entry. The framework packages — `react`,
27
27
  `vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
28
- need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
29
- `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.)
30
32
 
31
33
  > **Vite — one-line config.** Vite's dependency pre-bundling (esbuild) hoists
32
34
  > the wasm-bindgen glue into `.vite/deps/`, which breaks the relative
@@ -44,43 +46,35 @@ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
44
46
 
45
47
  <a id="nextjs"></a>
46
48
 
47
- > **Next.js (App Router) — two requirements.** Verified on **Next.js 16** with
48
- > **Turbopack** (the default for both `next dev` and `next build`). The same two
49
- > requirements apply under webpack. Because flux-md ships TypeScript source:
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.
50
55
  >
51
- > 1. **Transpile the package.** Next does not compile `node_modules` TypeScript
52
- > by default without this, Turbopack errors with *"Unknown module type"* on
53
- > `react.tsx`. Add flux-md to `transpilePackages`:
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.)
54
61
  >
55
- > ```ts
56
- > // next.config.ts
57
- > import type { NextConfig } from "next";
58
- > const nextConfig: NextConfig = { transpilePackages: ["flux-md"] };
59
- > export default nextConfig;
60
- > ```
62
+ > ```tsx
63
+ > "use client";
64
+ > import { FluxMarkdown } from "flux-md/react";
61
65
  >
62
- > 2. **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
63
- > spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
64
- > a Server Component. (It is still SSR-safe: on the server it renders an empty
65
- > shell and only starts streaming after hydration, so there's no SSR crash —
66
- > the constraint is hooks, not the worker.)
67
- >
68
- > ```tsx
69
- > "use client";
70
- > import { FluxMarkdown } from "flux-md/react";
71
- >
72
- > export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
73
- > return <FluxMarkdown stream={stream} />;
74
- > }
75
- > ```
66
+ > export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
67
+ > return <FluxMarkdown stream={stream} />;
68
+ > }
69
+ > ```
76
70
  >
77
- > **Create the `stream` in Client Component code, not in a Server Component.**
78
- > A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
79
- > can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
80
- > throws *"Only plain objects can be passed to Client Components."* Pass a
81
- > serializable prop (a URL, the chat messages) from the server and open the
82
- > stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
83
- > 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)).
84
78
  >
85
79
  > That's it — Turbopack bundles the worker and emits the `.wasm` to
86
80
  > `_next/static/media` itself, so no extra asset/loader config is needed (and the
@@ -427,12 +421,15 @@ const html = renderToString("# Hello\n\n**world**"); // sync HTML string, no w
427
421
  ```
428
422
 
429
423
  For React server rendering (RSC, static generation, or SSR), use
430
- `<FluxMarkdownStatic>` — a hookless, RSC-safe component that renders finished
431
- content with the same `components` overrides (inline/block component tags
432
- 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:
433
429
 
434
430
  ```tsx
435
- import { initFlux, FluxMarkdownStatic } from "flux-md/server";
431
+ import { initFlux } from "flux-md/server";
432
+ import { FluxMarkdownStatic } from "flux-md/server/react";
436
433
 
437
434
  await initFlux();
438
435
  export default function Doc({ md }: { md: string }) {
@@ -451,10 +448,11 @@ export default function Doc({ md }: { md: string }) {
451
448
  bundler-resolved asset. On edge runtimes pass bytes yourself:
452
449
  `initFluxSync(wasmBytes)`.
453
450
  - **`renderToString(md, { config })`** — synchronous HTML string, **zero React
454
- dependency**.
451
+ dependency** (imports cleanly with no `react` installed).
455
452
  - **`parseToBlocks(md, { config })`** — the block array, for custom rendering.
456
- - **`<FluxMarkdownStatic content config components />`** — synchronous React tree
457
- 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
458
456
  (`renderToStaticMarkup`, RSC, …). For live streaming, client-side code
459
457
  highlighting, or Mermaid, render `<FluxMarkdown>` on the client instead — it's a
460
458
  separate component. (If you SSR-then-hydrate, use the *same* component on both
@@ -1161,7 +1159,7 @@ re-snapping during streaming, so treat smooth following there as best-effort.
1161
1159
  │ microtask, calls WASM │
1162
1160
  └──┬──── ffi ───────────────────────────┘
1163
1161
 
1164
- ┌── Rust → WASM (~150 KB after opt) ────┐
1162
+ ┌── Rust → WASM (~170 KB after opt) ────┐
1165
1163
  │ StreamParser: │
1166
1164
  │ buffer: append-only │
1167
1165
  │ committed_offset │
package/dist/client.d.ts CHANGED
@@ -132,6 +132,7 @@ export declare class FluxClient {
132
132
  private coalesce;
133
133
  private rafHandle;
134
134
  private finalizePending;
135
+ private epoch;
135
136
  private appendedBytes;
136
137
  private patchCount;
137
138
  private totalParseMicros;
package/dist/client.js CHANGED
@@ -141,6 +141,12 @@ class FluxPool {
141
141
  }
142
142
  }
143
143
  for (const sid of pw.streamIds) this.dispatch(sid, msg);
144
+ try {
145
+ pw.worker.terminate();
146
+ } catch {
147
+ }
148
+ const idx = this.workers.indexOf(pw);
149
+ if (idx !== -1) this.workers.splice(idx, 1);
144
150
  return;
145
151
  }
146
152
  this.dispatch(msg.streamId, msg);
@@ -200,8 +206,13 @@ class FluxClient {
200
206
  coalesce = false;
201
207
  rafHandle = null;
202
208
  // Set by finalize(); the next patch's emit flushes synchronously (a 'done'
203
- // notification must not be deferred a frame) and clears it.
209
+ // notification must not be deferred a frame) and clears it. Belt-and-suspenders
210
+ // alongside the per-patch `final` flag, which is the authoritative signal.
204
211
  finalizePending = false;
212
+ // Stream generation, bumped on reset(). Stamped on every worker message and
213
+ // echoed back on each patch; a patch whose epoch is older than this is a
214
+ // pre-reset straggler and is dropped before it can repopulate the cleared store.
215
+ epoch = 0;
205
216
  // Perf
206
217
  appendedBytes = 0;
207
218
  patchCount = 0;
@@ -259,7 +270,8 @@ class FluxClient {
259
270
  * multiplexing (pick() is unchanged and remains the only path to create()).
260
271
  */
261
272
  ensureAcquired() {
262
- if (this.pw) return this.pw;
273
+ if (this.pw && !this.pw.failed) return this.pw;
274
+ this.pw = null;
263
275
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
264
276
  this.streamId = streamId;
265
277
  this.pw = pw;
@@ -283,12 +295,12 @@ class FluxClient {
283
295
  append(chunk) {
284
296
  const pw = this.ensureAcquired();
285
297
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
286
- this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
298
+ this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
287
299
  }
288
300
  finalize() {
289
301
  const pw = this.ensureAcquired();
290
302
  this.finalizePending = true;
291
- this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
303
+ this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
292
304
  }
293
305
  /**
294
306
  * Pipe a source straight in: read it to completion, `append()` each chunk,
@@ -402,8 +414,9 @@ class FluxClient {
402
414
  this.contentDone = false;
403
415
  this.cancelFrame();
404
416
  this.finalizePending = false;
417
+ this.epoch += 1;
405
418
  const pw = this.ensureAcquired();
406
- this.pool.send(pw, { type: "reset", streamId: this.streamId });
419
+ this.pool.send(pw, { type: "reset", streamId: this.streamId, epoch: this.epoch });
407
420
  if (hadContent) this.emit(true);
408
421
  }
409
422
  destroy() {
@@ -511,6 +524,7 @@ class FluxClient {
511
524
  onMessage(msg) {
512
525
  switch (msg.type) {
513
526
  case "patch": {
527
+ if (msg.epoch !== void 0 && msg.epoch < this.epoch) break;
514
528
  let patch;
515
529
  try {
516
530
  patch = JSON.parse(msg.patch);
@@ -527,7 +541,7 @@ class FluxClient {
527
541
  this.wasmMemoryBytes = msg.wasmMemoryBytes;
528
542
  this.patchCount += 1;
529
543
  this.lastPatchMs = performance.now();
530
- const sync = this.finalizePending;
544
+ const sync = this.finalizePending || msg.final === true;
531
545
  this.finalizePending = false;
532
546
  this.emit(sync);
533
547
  if (this.onBlock) {
@@ -0,0 +1,32 @@
1
+ import { type ReactNode } from "react";
2
+ import type { Components, ParserConfig } from "./types.js";
3
+ interface FluxMarkdownStaticProps {
4
+ /** The complete markdown to render (server/static use is for finished content). */
5
+ content: string;
6
+ /** Parser config (same shape as the streaming client's). */
7
+ config?: ParserConfig;
8
+ /** Tag-level / block-kind / component-tag overrides (see {@link Components}). */
9
+ components?: Components;
10
+ /** Appended to the root's `className` (the `flux-md` class is always present). */
11
+ className?: string;
12
+ /** Set on the root element. */
13
+ id?: string;
14
+ /** Set on the root element (e.g. `"article"`). */
15
+ role?: string;
16
+ /** Make the root a live region (parity with `<FluxMarkdown>` if you hydrate). */
17
+ "aria-live"?: "off" | "polite" | "assertive";
18
+ /** Live-region atomicity; pair with `aria-live`. */
19
+ "aria-atomic"?: boolean;
20
+ }
21
+ /**
22
+ * Synchronous, worker-free React rendering of finished markdown — a React Server
23
+ * Component, or any one-shot SSR / static render. Emits the `flux-md` root +
24
+ * per-block structure with the same `components` overrides (inline/block
25
+ * component tags dispatch here too). Requires `initFlux` (or `initFluxSync`)
26
+ * from `flux-md/server` to have run. Uses no hooks (RSC-safe). A **render-once**
27
+ * component: for live streaming, client-side code highlighting, or Mermaid use
28
+ * the client `<FluxMarkdown>` instead (and if you SSR-then-hydrate, render the
29
+ * *same* component on both sides).
30
+ */
31
+ export declare function FluxMarkdownStatic({ content, config, components, className, id, role, "aria-live": ariaLive, "aria-atomic": ariaAtomic, }: FluxMarkdownStaticProps): ReactNode;
32
+ export {};
@@ -0,0 +1,48 @@
1
+ import { createElement } from "react";
2
+ import { htmlToReact } from "./html-to-react.js";
3
+ import { blockKindProps } from "./react.js";
4
+ import { parseToBlocks } from "./server.js";
5
+ function renderStaticBlock(block, components) {
6
+ const kind = block.kind.type;
7
+ if (components) {
8
+ if (kind === "Component") {
9
+ const tag = block.kind.data?.tag;
10
+ const override = tag && components[tag] || components.Component;
11
+ if (override) return createElement(override, { key: block.id, ...blockKindProps(block, components) });
12
+ }
13
+ const blockOverride = components[kind];
14
+ if (blockOverride) return createElement(blockOverride, { key: block.id, ...blockKindProps(block, components) });
15
+ }
16
+ const className = "flux-block flux-block-" + kind.toLowerCase() + (block.open ? " flux-open" : "") + (block.speculative ? " flux-speculative" : "");
17
+ if (components) {
18
+ return createElement("div", { key: block.id, className }, htmlToReact(block.html, components));
19
+ }
20
+ return createElement("div", { key: block.id, className, dangerouslySetInnerHTML: { __html: block.html } });
21
+ }
22
+ function FluxMarkdownStatic({
23
+ content,
24
+ config,
25
+ components,
26
+ className,
27
+ id,
28
+ role,
29
+ "aria-live": ariaLive,
30
+ "aria-atomic": ariaAtomic
31
+ }) {
32
+ const blocks = parseToBlocks(content, { config });
33
+ const comps = components && Object.keys(components).length > 0 ? components : void 0;
34
+ return createElement(
35
+ "div",
36
+ {
37
+ className: className ? `flux-md ${className}` : "flux-md",
38
+ id,
39
+ role,
40
+ "aria-live": ariaLive,
41
+ "aria-atomic": ariaAtomic
42
+ },
43
+ blocks.map((b) => renderStaticBlock(b, comps))
44
+ );
45
+ }
46
+ export {
47
+ FluxMarkdownStatic
48
+ };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { type ReactNode } from "react";
2
- import type { Block, Components, ParserConfig } from "./types.js";
1
+ import type { Block, ParserConfig } from "./types.js";
3
2
  /** Has the sync WASM core been initialized in this process? */
4
3
  export declare function isFluxReady(): boolean;
5
4
  /** Initialize the sync core from compiled WASM bytes (or a `WebAssembly.Module`).
@@ -24,38 +23,9 @@ export declare function parseToBlocks(markdown: string, opts?: {
24
23
  * Render a complete markdown string to an HTML string synchronously — no worker,
25
24
  * no React. The concatenated per-block HTML (XSS-safe with `unsafeHtml` off).
26
25
  * For component dispatch / a `<FluxMarkdown>`-matching React tree, use
27
- * {@link FluxMarkdownStatic} with your framework's server renderer instead.
26
+ * `FluxMarkdownStatic` from `flux-md/server/react` with your framework's server
27
+ * renderer instead.
28
28
  */
29
29
  export declare function renderToString(markdown: string, opts?: {
30
30
  config?: ParserConfig;
31
31
  }): string;
32
- interface FluxMarkdownStaticProps {
33
- /** The complete markdown to render (server/static use is for finished content). */
34
- content: string;
35
- /** Parser config (same shape as the streaming client's). */
36
- config?: ParserConfig;
37
- /** Tag-level / block-kind / component-tag overrides (see {@link Components}). */
38
- components?: Components;
39
- /** Appended to the root's `className` (the `flux-md` class is always present). */
40
- className?: string;
41
- /** Set on the root element. */
42
- id?: string;
43
- /** Set on the root element (e.g. `"article"`). */
44
- role?: string;
45
- /** Make the root a live region (parity with `<FluxMarkdown>` if you hydrate). */
46
- "aria-live"?: "off" | "polite" | "assertive";
47
- /** Live-region atomicity; pair with `aria-live`. */
48
- "aria-atomic"?: boolean;
49
- }
50
- /**
51
- * Synchronous, worker-free React rendering of finished markdown — a React Server
52
- * Component, or any one-shot SSR / static render. Emits the `flux-md` root +
53
- * per-block structure with the same `components` overrides (inline/block
54
- * component tags dispatch here too). Requires {@link initFlux} (or
55
- * {@link initFluxSync}) to have run. Uses no hooks (RSC-safe). A **render-once**
56
- * component: for live streaming, client-side code highlighting, or Mermaid use
57
- * the client `<FluxMarkdown>` instead (and if you SSR-then-hydrate, render the
58
- * *same* component on both sides).
59
- */
60
- export declare function FluxMarkdownStatic({ content, config, components, className, id, role, "aria-live": ariaLive, "aria-atomic": ariaAtomic, }: FluxMarkdownStaticProps): ReactNode;
61
- export {};
package/dist/server.js CHANGED
@@ -1,7 +1,4 @@
1
- import { createElement } from "react";
2
1
  import initWasmAsync, { FluxParser, initSync } from "./wasm/flux_md_core.js";
3
- import { htmlToReact } from "./html-to-react.js";
4
- import { blockKindProps } from "./react.js";
5
2
  let ready = false;
6
3
  function isFluxReady() {
7
4
  return ready;
@@ -75,49 +72,7 @@ function parseToBlocks(markdown, opts) {
75
72
  function renderToString(markdown, opts) {
76
73
  return parseToBlocks(markdown, opts).map((b) => b.html).join("");
77
74
  }
78
- function renderStaticBlock(block, components) {
79
- const kind = block.kind.type;
80
- if (components) {
81
- if (kind === "Component") {
82
- const tag = block.kind.data?.tag;
83
- const override = tag && components[tag] || components.Component;
84
- if (override) return createElement(override, { key: block.id, ...blockKindProps(block, components) });
85
- }
86
- const blockOverride = components[kind];
87
- if (blockOverride) return createElement(blockOverride, { key: block.id, ...blockKindProps(block, components) });
88
- }
89
- const className = "flux-block flux-block-" + kind.toLowerCase() + (block.open ? " flux-open" : "") + (block.speculative ? " flux-speculative" : "");
90
- if (components) {
91
- return createElement("div", { key: block.id, className }, htmlToReact(block.html, components));
92
- }
93
- return createElement("div", { key: block.id, className, dangerouslySetInnerHTML: { __html: block.html } });
94
- }
95
- function FluxMarkdownStatic({
96
- content,
97
- config,
98
- components,
99
- className,
100
- id,
101
- role,
102
- "aria-live": ariaLive,
103
- "aria-atomic": ariaAtomic
104
- }) {
105
- const blocks = parseToBlocks(content, { config });
106
- const comps = components && Object.keys(components).length > 0 ? components : void 0;
107
- return createElement(
108
- "div",
109
- {
110
- className: className ? `flux-md ${className}` : "flux-md",
111
- id,
112
- role,
113
- "aria-live": ariaLive,
114
- "aria-atomic": ariaAtomic
115
- },
116
- blocks.map((b) => renderStaticBlock(b, comps))
117
- );
118
- }
119
75
  export {
120
- FluxMarkdownStatic,
121
76
  initFlux,
122
77
  initFluxSync,
123
78
  isFluxReady,
@@ -336,13 +336,16 @@ export type ToWorker = {
336
336
  streamId: number;
337
337
  chunk: string;
338
338
  config?: ParserConfig;
339
+ epoch?: number;
339
340
  } | {
340
341
  type: "finalize";
341
342
  streamId: number;
342
343
  config?: ParserConfig;
344
+ epoch?: number;
343
345
  } | {
344
346
  type: "reset";
345
347
  streamId: number;
348
+ epoch?: number;
346
349
  } | {
347
350
  type: "dispose";
348
351
  streamId: number;
@@ -357,6 +360,8 @@ export type FromWorker = {
357
360
  parseMicros: number;
358
361
  retainedBytes: number;
359
362
  wasmMemoryBytes: number;
363
+ final?: boolean;
364
+ epoch?: number;
360
365
  } | {
361
366
  type: "error";
362
367
  streamId: number;
Binary file
@@ -44,6 +44,7 @@ export declare class WorkerCore {
44
44
  private pending;
45
45
  private totalAppended;
46
46
  private finalizePending;
47
+ private epochs;
47
48
  private flushScheduled;
48
49
  private ready;
49
50
  constructor(deps: WorkerCoreDeps);
@@ -53,8 +54,12 @@ export declare class WorkerCore {
53
54
  markReady(): void;
54
55
  private getOrCreate;
55
56
  private dispose;
57
+ /** free() a stream's parser, swallowing a throw from a poisoned WASM instance
58
+ * so teardown can never escape the message loop. */
59
+ private safeFree;
56
60
  private emitPatch;
57
61
  private scheduleFlush;
58
62
  private flush;
59
63
  private doFinalize;
64
+ private postParseError;
60
65
  }
@@ -9,6 +9,11 @@ class WorkerCore {
9
9
  pending = /* @__PURE__ */ new Map();
10
10
  totalAppended = /* @__PURE__ */ new Map();
11
11
  finalizePending = /* @__PURE__ */ new Set();
12
+ // Per-stream generation, echoed on emitted patches so the client can drop a
13
+ // patch produced before a reset (see FromWorker.epoch). A microtask flush
14
+ // drains `pending` between messages, so the epoch at emit time is always the
15
+ // epoch the buffered input was appended under.
16
+ epochs = /* @__PURE__ */ new Map();
12
17
  flushScheduled = false;
13
18
  ready = false;
14
19
  /** Handle one message from the main thread (append/finalize/reset/dispose). */
@@ -17,6 +22,9 @@ class WorkerCore {
17
22
  if ((msg.type === "append" || msg.type === "finalize") && msg.config) {
18
23
  this.config.set(id, msg.config);
19
24
  }
25
+ if (msg.type !== "dispose" && msg.epoch !== void 0) {
26
+ this.epochs.set(id, msg.epoch);
27
+ }
20
28
  switch (msg.type) {
21
29
  case "append":
22
30
  this.pending.set(id, (this.pending.get(id) ?? "") + msg.chunk);
@@ -27,7 +35,7 @@ class WorkerCore {
27
35
  else this.doFinalize(id);
28
36
  break;
29
37
  case "reset":
30
- this.parsers.get(id)?.free();
38
+ this.safeFree(id);
31
39
  this.parsers.delete(id);
32
40
  this.pending.delete(id);
33
41
  this.finalizePending.delete(id);
@@ -57,14 +65,23 @@ class WorkerCore {
57
65
  return p;
58
66
  }
59
67
  dispose(streamId) {
60
- this.parsers.get(streamId)?.free();
68
+ this.safeFree(streamId);
61
69
  this.parsers.delete(streamId);
62
70
  this.config.delete(streamId);
63
71
  this.pending.delete(streamId);
64
72
  this.finalizePending.delete(streamId);
65
73
  this.totalAppended.delete(streamId);
74
+ this.epochs.delete(streamId);
75
+ }
76
+ /** free() a stream's parser, swallowing a throw from a poisoned WASM instance
77
+ * so teardown can never escape the message loop. */
78
+ safeFree(streamId) {
79
+ try {
80
+ this.parsers.get(streamId)?.free();
81
+ } catch {
82
+ }
66
83
  }
67
- emitPatch(streamId, patch, parser, parseMicros) {
84
+ emitPatch(streamId, patch, parser, parseMicros, final) {
68
85
  this.deps.post({
69
86
  type: "patch",
70
87
  streamId,
@@ -72,7 +89,9 @@ class WorkerCore {
72
89
  appendedBytes: this.totalAppended.get(streamId) ?? 0,
73
90
  parseMicros,
74
91
  retainedBytes: parser.retainedBytes(),
75
- wasmMemoryBytes: this.deps.memBytes()
92
+ wasmMemoryBytes: this.deps.memBytes(),
93
+ final,
94
+ epoch: this.epochs.get(streamId)
76
95
  });
77
96
  }
78
97
  scheduleFlush() {
@@ -92,9 +111,9 @@ class WorkerCore {
92
111
  const patch = parser.append(chunk);
93
112
  const dt = (performance.now() - t0) * 1e3;
94
113
  this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + chunk.length);
95
- this.emitPatch(streamId, patch, parser, dt);
114
+ this.emitPatch(streamId, patch, parser, dt, false);
96
115
  } catch (e) {
97
- this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
116
+ this.postParseError(streamId, e);
98
117
  }
99
118
  }
100
119
  }
@@ -110,11 +129,25 @@ class WorkerCore {
110
129
  this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + buffered.length);
111
130
  }
112
131
  const patch = parser.finalize();
113
- this.emitPatch(streamId, patch, parser, 0);
132
+ this.emitPatch(streamId, patch, parser, 0, true);
114
133
  } catch (e) {
115
- this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
134
+ this.postParseError(streamId, e);
116
135
  }
117
136
  }
137
+ // A WASM trap (stack overflow / unreachable / OOM) throws a
138
+ // WebAssembly.RuntimeError and POISONS the singleton instance shared by every
139
+ // stream on this worker — so it is escalated to `fatal`, which the pool treats
140
+ // as a worker-level failure (eviction + terminate). A plain Error (e.g. a
141
+ // parser-construction failure) stays a recoverable per-stream error.
142
+ postParseError(streamId, e) {
143
+ const fatal = typeof WebAssembly !== "undefined" && typeof WebAssembly.RuntimeError === "function" && e instanceof WebAssembly.RuntimeError;
144
+ this.deps.post({
145
+ type: "error",
146
+ streamId,
147
+ message: e instanceof Error ? e.message : String(e),
148
+ fatal
149
+ });
150
+ }
118
151
  }
119
152
  export {
120
153
  WorkerCore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.17.0",
3
+ "version": "0.18.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": ["./dist/worker.js", "./dist/styles.css"],
@@ -11,6 +11,7 @@
11
11
  "./client": { "types": "./dist/client.d.ts", "import": "./dist/client.js" },
12
12
  "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.js" },
13
13
  "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" },
14
+ "./server/react": { "types": "./dist/server-react.d.ts", "import": "./dist/server-react.js" },
14
15
  "./dom": { "types": "./dist/dom.d.ts", "import": "./dist/dom.js" },
15
16
  "./element": { "types": "./dist/element.d.ts", "import": "./dist/element.js" },
16
17
  "./vue": { "types": "./dist/vue.d.ts", "import": "./dist/vue.js" },
@@ -23,7 +24,8 @@
23
24
  },
24
25
  "files": [
25
26
  "dist",
26
- "README.md"
27
+ "README.md",
28
+ "CHANGELOG.md"
27
29
  ],
28
30
  "peerDependencies": {
29
31
  "react": ">=18",