flux-md 0.12.0 → 0.13.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 +52 -0
- package/README.md +159 -7
- package/package.json +1 -1
- package/src/client.ts +74 -0
- package/src/index.ts +1 -1
- package/src/react.tsx +63 -5
- package/src/solid.tsx +72 -2
- package/src/svelte.ts +100 -1
- package/src/vue.ts +58 -1
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/package.json +1 -1
- package/src/worker-core.ts +174 -0
- package/src/worker.ts +39 -136
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ 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.13.0 — 2026-06-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`FluxClient.setContent(content, { done })` + controlled-string helpers for
|
|
12
|
+
every binding** — a first-class bridge for UIs that hold a streaming message as
|
|
13
|
+
a single growing/controlled string prop (rather than a stream). setContent diffs
|
|
14
|
+
against the last value: a **prefix-extension** appends only the delta (committed
|
|
15
|
+
blocks stay put); any **divergence** (e.g. a finished message swapped for a
|
|
16
|
+
re-processed final string) resets and reparses. No hand-rolled diff, no
|
|
17
|
+
readiness gate. Pass `{ done: true }` / `streaming: false` to finalize. The
|
|
18
|
+
framework-neutral `setContent` is wrapped by an idiomatic, client-owning helper
|
|
19
|
+
per framework — React `useFluxMarkdownString`, Vue `useFluxMarkdownString`
|
|
20
|
+
(composable), Solid `createFluxMarkdownString`, Svelte `fluxMarkdownString`
|
|
21
|
+
(action) — each SSR-safe (feeds only in the client-only lifecycle hook). Vanilla
|
|
22
|
+
/ `<flux-markdown>` use a caller-owned client + `setContent` directly.
|
|
23
|
+
- **`FluxPool.warm()`** — eagerly initialize one worker (`getDefaultPool().warm()`
|
|
24
|
+
on app load) so the one-time WASM init is off the first-token critical path; the
|
|
25
|
+
warm worker is the one the first stream attaches to, so the work isn't wasted.
|
|
26
|
+
- **Custom-component & `sanitize` overrides now apply to the OPEN (streaming)
|
|
27
|
+
block**, not just settled ones — a design-system renderer (Tailwind classes on
|
|
28
|
+
`p`/`ul`/`li`, inline `<a>`/`<code>` overrides) stays styled mid-stream instead
|
|
29
|
+
of only after a block commits. This also closes a gap where a supplied
|
|
30
|
+
`sanitize` previously bypassed component-rendered blocks; it now runs on every
|
|
31
|
+
block. The no-`components` path is unchanged (byte-identical `innerHTML`).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Worker no longer drops the first chunk(s) under a slow WASM load.** The
|
|
36
|
+
worker buffered appends but did not gate parser creation on WASM readiness, so
|
|
37
|
+
an append that arrived before `init()` resolved would call `new FluxParser()`
|
|
38
|
+
against an uninitialized module — throwing `fluxparser_new of undefined` and
|
|
39
|
+
silently losing that chunk. Appends now accumulate (and `finalize` defers)
|
|
40
|
+
until init completes, then drain in order. Surfaced on a fresh Next.js /
|
|
41
|
+
Turbopack production load, where the worker+WASM fetch is slow enough to lose
|
|
42
|
+
the race; the fix is bundler-agnostic. The worker's message/readiness state
|
|
43
|
+
machine was extracted to `worker-core.ts` (dependency-injected, like
|
|
44
|
+
`FluxPool`'s worker factory) and now has a unit test (`worker-core.test.ts`)
|
|
45
|
+
covering the gate — buffer-until-ready, drain order, finalize/reset before
|
|
46
|
+
ready — so the regression can't silently return.
|
|
47
|
+
- **React 19 / Next.js type compatibility.** The shipped source used the global
|
|
48
|
+
`JSX.Element`, which React 19's `@types/react` removed — a consumer's
|
|
49
|
+
`next build` type-checks flux-md's source (it ships as `.tsx`) and failed with
|
|
50
|
+
*"Cannot find namespace 'JSX'"*. Now uses `ReactElement`, which type-checks
|
|
51
|
+
under `@types/react` 18 **and** 19.
|
|
52
|
+
|
|
53
|
+
### Docs
|
|
54
|
+
|
|
55
|
+
- **Next.js (App Router) is now documented and verified** (Turbopack + webpack,
|
|
56
|
+
Next.js 16, `next dev` and `next build`): add flux-md to `transpilePackages`
|
|
57
|
+
and use it from a `"use client"` component. See the README's Next.js callout.
|
|
58
|
+
|
|
7
59
|
## 0.12.0 — 2026-05-30
|
|
8
60
|
|
|
9
61
|
### Added
|
package/README.md
CHANGED
|
@@ -16,9 +16,9 @@ flux-md ships as **source** (TypeScript + the compiled WASM). The worker and
|
|
|
16
16
|
WASM asset are referenced with the **web-standard `new URL(asset,
|
|
17
17
|
import.meta.url)`** pattern, so any bundler with asset-module support resolves
|
|
18
18
|
them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
|
|
19
|
-
modules), and **
|
|
20
|
-
|
|
21
|
-
Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
|
|
19
|
+
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
|
+
**browser-only** (it constructs Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
|
|
22
22
|
`vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
|
|
23
23
|
need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
24
24
|
`flux-md/dom`, `flux-md/element`) needs none.
|
|
@@ -37,6 +37,54 @@ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
|
|
|
37
37
|
>
|
|
38
38
|
> No other bundler needs this — it's specific to Vite's optimizer.
|
|
39
39
|
|
|
40
|
+
<a id="nextjs"></a>
|
|
41
|
+
|
|
42
|
+
> **Next.js (App Router) — two requirements.** Verified on **Next.js 16** with
|
|
43
|
+
> **Turbopack** (the default for both `next dev` and `next build`). The same two
|
|
44
|
+
> requirements apply under webpack. Because flux-md ships TypeScript source:
|
|
45
|
+
>
|
|
46
|
+
> 1. **Transpile the package.** Next does not compile `node_modules` TypeScript
|
|
47
|
+
> by default — without this, Turbopack errors with *"Unknown module type"* on
|
|
48
|
+
> `react.tsx`. Add flux-md to `transpilePackages`:
|
|
49
|
+
>
|
|
50
|
+
> ```ts
|
|
51
|
+
> // next.config.ts
|
|
52
|
+
> import type { NextConfig } from "next";
|
|
53
|
+
> const nextConfig: NextConfig = { transpilePackages: ["flux-md"] };
|
|
54
|
+
> export default nextConfig;
|
|
55
|
+
> ```
|
|
56
|
+
>
|
|
57
|
+
> 2. **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
|
|
58
|
+
> spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
|
|
59
|
+
> a Server Component. (It is still SSR-safe: on the server it renders an empty
|
|
60
|
+
> shell and only starts streaming after hydration, so there's no SSR crash —
|
|
61
|
+
> the constraint is hooks, not the worker.)
|
|
62
|
+
>
|
|
63
|
+
> ```tsx
|
|
64
|
+
> "use client";
|
|
65
|
+
> import { FluxMarkdown } from "flux-md/react";
|
|
66
|
+
>
|
|
67
|
+
> export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
|
|
68
|
+
> return <FluxMarkdown stream={stream} />;
|
|
69
|
+
> }
|
|
70
|
+
> ```
|
|
71
|
+
>
|
|
72
|
+
> **Create the `stream` in Client Component code, not in a Server Component.**
|
|
73
|
+
> A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
|
|
74
|
+
> can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
|
|
75
|
+
> throws *"Only plain objects can be passed to Client Components."* Pass a
|
|
76
|
+
> serializable prop (a URL, the chat messages) from the server and open the
|
|
77
|
+
> stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
|
|
78
|
+
> effect, or the `useFluxStream` hook (see [Quick start](#quick-start)).
|
|
79
|
+
>
|
|
80
|
+
> That's it — Turbopack bundles the worker and emits the `.wasm` to
|
|
81
|
+
> `_next/static/media` itself, so no extra asset/loader config is needed (and the
|
|
82
|
+
> Vite `optimizeDeps` workaround above does **not** apply). Both `next dev` and
|
|
83
|
+
> `next build && next start` are verified to spawn the worker, load the WASM, and
|
|
84
|
+
> stream markdown. _Dev tip:_ open the app on `localhost` — Next dev blocks
|
|
85
|
+
> cross-origin dev resources (HMR, chunks) from other hosts (e.g. `127.0.0.1`)
|
|
86
|
+
> unless you add them to `allowedDevOrigins` in `next.config`.
|
|
87
|
+
|
|
40
88
|
## Quick start
|
|
41
89
|
|
|
42
90
|
```ts
|
|
@@ -79,6 +127,38 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
|
|
|
79
127
|
}
|
|
80
128
|
```
|
|
81
129
|
|
|
130
|
+
### Already holding a growing string? — `useFluxMarkdownString`
|
|
131
|
+
|
|
132
|
+
Many apps keep the streaming message as a **single growing string prop** (it
|
|
133
|
+
re-renders with the full text-so-far each token), not as a stream. Feed that
|
|
134
|
+
string straight in — `useFluxMarkdownString` diffs it for you and forwards only
|
|
135
|
+
the delta, so you don't hand-roll an append/reset bridge:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import { FluxMarkdown, useFluxMarkdownString } from "flux-md/react";
|
|
139
|
+
|
|
140
|
+
export function ChatMessage({ text, streaming }: { text: string; streaming: boolean }) {
|
|
141
|
+
const client = useFluxMarkdownString(text, { streaming });
|
|
142
|
+
return <FluxMarkdown client={client} />;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It handles the two shapes a controlled string takes: a **prefix-extension** (the
|
|
147
|
+
common token-by-token growth) appends only the new suffix; a **divergence** (e.g.
|
|
148
|
+
the finished text swapped for a re-processed final string — bolded numbers,
|
|
149
|
+
wrapped tickers) resets and reparses. Pass `streaming: false` once the content is
|
|
150
|
+
final so the last block commits (a finished code fence then highlights). The
|
|
151
|
+
framework-neutral primitive is **`client.setContent(fullString, { done })`** —
|
|
152
|
+
use it from any binding.
|
|
153
|
+
|
|
154
|
+
> **Transforming streamed content?** If the enrichment runs **live per token**
|
|
155
|
+
> (e.g. bold every number as it arrives), do it at **render time** via
|
|
156
|
+
> [`components`](#custom-components--overrides) — keep the markdown source
|
|
157
|
+
> append-only so parsing stays incremental. Re-transforming the *whole* string
|
|
158
|
+
> each token (so earlier bytes change) forces `setContent` to reparse every tick
|
|
159
|
+
> (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
|
|
160
|
+
> for the **once**-at-the-end reprocess swap, not per-token rewrites.
|
|
161
|
+
|
|
82
162
|
<details>
|
|
83
163
|
<summary>Full manual control (caller-owned client)</summary>
|
|
84
164
|
|
|
@@ -150,6 +230,12 @@ handle.destroy();
|
|
|
150
230
|
client.destroy();
|
|
151
231
|
```
|
|
152
232
|
|
|
233
|
+
**Already holding a growing string?** There's no framework reactivity to wrap,
|
|
234
|
+
so just call **`client.setContent(fullString, { done })`** instead of the
|
|
235
|
+
`append` loop — it diffs internally (prefix → delta; divergence → reparse) and
|
|
236
|
+
finalizes on `done`. That's the same primitive the React/Vue/Svelte/Solid
|
|
237
|
+
controlled-string helpers wrap; in vanilla you call it directly.
|
|
238
|
+
|
|
153
239
|
`mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
|
|
154
240
|
Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
|
|
155
241
|
(default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
|
|
@@ -208,6 +294,13 @@ defineFluxMarkdown(); // once at bootstrap
|
|
|
208
294
|
export class Answer { url = "/api/post.md"; }
|
|
209
295
|
```
|
|
210
296
|
|
|
297
|
+
**Controlled growing string?** Assign a caller-owned client and drive it with
|
|
298
|
+
`setContent` — `el.client = myClient; myClient.setContent(fullString, { done })`
|
|
299
|
+
— the element subscribes and renders, you own the diffing. (The self-owned
|
|
300
|
+
`markdown` attribute is **one-shot** — it re-parses the whole document on each
|
|
301
|
+
change, so don't point it at a per-token-growing string; use a client +
|
|
302
|
+
`setContent` for that.)
|
|
303
|
+
|
|
211
304
|
### Vue 3 — `flux-md/vue`
|
|
212
305
|
|
|
213
306
|
```vue
|
|
@@ -230,6 +323,20 @@ Props: `client` (required), `components`, `sanitize`, `virtualize`,
|
|
|
230
323
|
`stickToBottom`. There's also a `useFluxMarkdown` composable returning a
|
|
231
324
|
`container` ref if you'd rather mount into your own element.
|
|
232
325
|
|
|
326
|
+
**Already holding a growing string?** `useFluxMarkdownString` owns a client and
|
|
327
|
+
diffs the string for you (the Vue analogue of the React hook — see
|
|
328
|
+
[Controlled strings](#already-holding-a-growing-string--usefluxmarkdownstring)):
|
|
329
|
+
|
|
330
|
+
```vue
|
|
331
|
+
<script setup lang="ts">
|
|
332
|
+
import { FluxMarkdown, useFluxMarkdownString } from "flux-md/vue";
|
|
333
|
+
const props = defineProps<{ text: string; streaming: boolean }>();
|
|
334
|
+
// Pass getters so the composable tracks the live values; it owns + destroys the client.
|
|
335
|
+
const client = useFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
|
|
336
|
+
</script>
|
|
337
|
+
<template><FluxMarkdown :client="client" /></template>
|
|
338
|
+
```
|
|
339
|
+
|
|
233
340
|
### Svelte (4 & 5) — `flux-md/svelte`
|
|
234
341
|
|
|
235
342
|
A Svelte action — works in both v4 and v5, no `.svelte` build step:
|
|
@@ -248,6 +355,20 @@ A Svelte action — works in both v4 and v5, no `.svelte` build step:
|
|
|
248
355
|
<div use:fluxMarkdown={{ client, stickToBottom: true }} />
|
|
249
356
|
```
|
|
250
357
|
|
|
358
|
+
**Growing string?** The `fluxMarkdownString` action owns a client and diffs the
|
|
359
|
+
string — `use:fluxMarkdownString={{ content, streaming }}` (it destroys its
|
|
360
|
+
client on `destroy`, so no manual cleanup):
|
|
361
|
+
|
|
362
|
+
```svelte
|
|
363
|
+
<script lang="ts">
|
|
364
|
+
import { fluxMarkdownString } from "flux-md/svelte";
|
|
365
|
+
export let content: string; // the growing message
|
|
366
|
+
export let streaming: boolean; // false once complete → finalizes
|
|
367
|
+
</script>
|
|
368
|
+
|
|
369
|
+
<div use:fluxMarkdownString={{ content, streaming, stickToBottom: true }} />
|
|
370
|
+
```
|
|
371
|
+
|
|
251
372
|
### Solid — `flux-md/solid`
|
|
252
373
|
|
|
253
374
|
```tsx
|
|
@@ -262,6 +383,19 @@ onCleanup(() => client.destroy());
|
|
|
262
383
|
<FluxMarkdown client={client} stickToBottom />;
|
|
263
384
|
```
|
|
264
385
|
|
|
386
|
+
**Growing string?** `createFluxMarkdownString` owns a client and diffs the string
|
|
387
|
+
(the Solid analogue of the React hook), driving `setContent` from a
|
|
388
|
+
`createEffect` and destroying the client on cleanup:
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
import { FluxMarkdown, createFluxMarkdownString } from "flux-md/solid";
|
|
392
|
+
|
|
393
|
+
function Message(props: { text: string; streaming: boolean }) {
|
|
394
|
+
const client = createFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
|
|
395
|
+
return <FluxMarkdown client={client} />;
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
265
399
|
The Solid binding's mount/teardown logic is tested, but its JSX component shell
|
|
266
400
|
has so far only been exercised through a real Solid (`vite-plugin-solid`) build
|
|
267
401
|
in development, not in CI — treat it as the newest of the bindings and file an
|
|
@@ -324,6 +458,10 @@ class FluxClient {
|
|
|
324
458
|
opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
|
|
325
459
|
): Promise<void>;
|
|
326
460
|
finalize(): void; // mark stream complete
|
|
461
|
+
setContent( // drive from a controlled full string
|
|
462
|
+
full: string, // diffs vs last: prefix → append delta; else reset+reparse
|
|
463
|
+
opts?: { done?: boolean }, // done:true → finalize
|
|
464
|
+
): void;
|
|
327
465
|
reset(): void; // wipe and reuse
|
|
328
466
|
destroy(): void; // free this stream's parser
|
|
329
467
|
whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
|
|
@@ -502,11 +640,15 @@ Rules worth knowing:
|
|
|
502
640
|
channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
|
|
503
641
|
`block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
|
|
504
642
|
`list` fields) directly — no HTML re-parsing.
|
|
505
|
-
- **
|
|
506
|
-
|
|
643
|
+
- **Overrides apply to the OPEN (streaming) block too**, not just settled ones —
|
|
644
|
+
so a design-system renderer (Tailwind classes on `p`/`ul`/`li`, inline
|
|
645
|
+
`<a>`/`<code>` overrides) stays styled mid-stream. The tail's HTML is always
|
|
646
|
+
well-formed (the parser speculatively closes it). If a `sanitize` is supplied
|
|
647
|
+
it runs first, on every block.
|
|
507
648
|
- **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
|
|
508
|
-
output). The HTML→React conversion
|
|
509
|
-
|
|
649
|
+
output). The HTML→React conversion runs only when you actually supply
|
|
650
|
+
overrides, and is memoized per `(block id, html)` so committed blocks don't
|
|
651
|
+
re-parse as the stream grows.
|
|
510
652
|
- For **code blocks** the built-in highlighter is the default; it is bypassed
|
|
511
653
|
(so your override wins) when you pass `components.CodeBlock`, `components.pre`,
|
|
512
654
|
or `components.code`.
|
|
@@ -736,6 +878,16 @@ teardown? Construct your own `new FluxPool(factory, cap)` and pass it to
|
|
|
736
878
|
**per-page singleton** — don't rely on it in SSR/RSC. For isolation between
|
|
737
879
|
independent feature areas, give each its own `new FluxPool()`.
|
|
738
880
|
|
|
881
|
+
**Warm the pool to hide WASM init.** The one-time WASM load happens on the first
|
|
882
|
+
worker-bound op, which lands on the first-token critical path. Call
|
|
883
|
+
`getDefaultPool().warm()` on app load / route entry to start it early — the warm
|
|
884
|
+
worker is the one the first stream attaches to, so the init isn't wasted:
|
|
885
|
+
|
|
886
|
+
```ts
|
|
887
|
+
import { getDefaultPool } from "flux-md";
|
|
888
|
+
useEffect(() => { getDefaultPool().warm(); }, []); // (or your framework's mount hook)
|
|
889
|
+
```
|
|
890
|
+
|
|
739
891
|
### Long documents — `virtualize`
|
|
740
892
|
|
|
741
893
|
For very long documents (hundreds+ of blocks), pass `virtualize` to apply CSS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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", "./src/styles.css"],
|
package/src/client.ts
CHANGED
|
@@ -151,6 +151,21 @@ export class FluxPool {
|
|
|
151
151
|
return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Eagerly spin up one worker so WASM init starts BEFORE the first stream —
|
|
156
|
+
* taking the one-time init off the first-token critical path (e.g. call
|
|
157
|
+
* `getDefaultPool().warm()` on app load / route entry). Reuses a live worker
|
|
158
|
+
* if one exists; the warm worker is the one the first stream attaches to (it
|
|
159
|
+
* has spare capacity), so the work is not wasted. Resolves when that worker has
|
|
160
|
+
* finished initializing WASM; rejects if init fails fatally. Browser-only (it
|
|
161
|
+
* constructs a `Worker`).
|
|
162
|
+
*/
|
|
163
|
+
warm(): Promise<void> {
|
|
164
|
+
const live = this.workers.filter((w) => !w.failed);
|
|
165
|
+
const pw = live[0] ?? this.create();
|
|
166
|
+
return this.whenWorkerReady(pw);
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
155
170
|
disposeAll(): void {
|
|
156
171
|
for (const pw of this.workers) {
|
|
@@ -286,6 +301,11 @@ export class FluxClient {
|
|
|
286
301
|
private onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
287
302
|
private onBlock?: (block: Block) => void;
|
|
288
303
|
private attached = true;
|
|
304
|
+
// Diff baseline for setContent(): the full string fed in so far, and whether
|
|
305
|
+
// it has been finalized. Cleared by reset()/reattach() (the worker drops the
|
|
306
|
+
// parser there, so the baseline is stale and the document must be re-fed).
|
|
307
|
+
private lastContent = "";
|
|
308
|
+
private contentDone = false;
|
|
289
309
|
|
|
290
310
|
// Perf
|
|
291
311
|
private appendedBytes = 0;
|
|
@@ -446,6 +466,53 @@ export class FluxClient {
|
|
|
446
466
|
}
|
|
447
467
|
}
|
|
448
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Drive the parser from a CONTROLLED full string instead of manual appends.
|
|
471
|
+
* Pass the whole document-so-far each time; setContent diffs it against the
|
|
472
|
+
* last value and does the minimal work:
|
|
473
|
+
* - **prefix-extension** (the streaming-growth case) → append only the new
|
|
474
|
+
* suffix, so committed blocks stay put and only the active tail re-parses;
|
|
475
|
+
* - **any other change** (e.g. a finished stream swapped for a re-processed
|
|
476
|
+
* final string) → `reset()` + reparse the whole new string.
|
|
477
|
+
*
|
|
478
|
+
* This is the first-class bridge for UIs that hold a streaming message as a
|
|
479
|
+
* single growing string prop (the common React shape) — no hand-rolled diff,
|
|
480
|
+
* no readiness gate (appends before WASM is ready are buffered). Pass
|
|
481
|
+
* `{ done: true }` once the content is final to `finalize()` (idempotent within
|
|
482
|
+
* a generation; a content change *after* done reopens the stream via a fresh
|
|
483
|
+
* reparse, since a finalized parser is terminal and can't be appended to).
|
|
484
|
+
* Drive a given client with `setContent` *or* manual `append()`/`finalize()`,
|
|
485
|
+
* not both — they share the internal diff baseline.
|
|
486
|
+
*
|
|
487
|
+
* v1 note: the non-prefix path is a full reparse, not a partial rewind —
|
|
488
|
+
* committed blocks are frozen, so there is no truncate-to-offset. For the
|
|
489
|
+
* common case (append-growth + one end-of-stream swap) that is optimal. A
|
|
490
|
+
* transform that rewrites *earlier* bytes on every update is an anti-pattern
|
|
491
|
+
* here (it forces a reparse each tick); do that enrichment at render time via
|
|
492
|
+
* `components` instead, keeping the source append-only.
|
|
493
|
+
*/
|
|
494
|
+
setContent(content: string, opts?: { done?: boolean }) {
|
|
495
|
+
if (content !== this.lastContent) {
|
|
496
|
+
// Fast path appends the delta into the EXISTING parser — but a parser that
|
|
497
|
+
// was already finalized ({ done: true }) is terminal: the core drops any
|
|
498
|
+
// further append. So gate the fast path on !contentDone; reopening a
|
|
499
|
+
// finalized stream (or any divergence) falls through to reset()+reparse,
|
|
500
|
+
// which frees the dead parser and rebuilds a fresh one.
|
|
501
|
+
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
502
|
+
this.append(content.slice(this.lastContent.length));
|
|
503
|
+
} else {
|
|
504
|
+
this.reset(); // diverged, or reopening a finalized stream — rebuild
|
|
505
|
+
this.append(content);
|
|
506
|
+
}
|
|
507
|
+
this.lastContent = content;
|
|
508
|
+
this.contentDone = false;
|
|
509
|
+
}
|
|
510
|
+
if (opts?.done && !this.contentDone) {
|
|
511
|
+
this.finalize();
|
|
512
|
+
this.contentDone = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
449
516
|
reset() {
|
|
450
517
|
this.store = emptyBlockStore();
|
|
451
518
|
this.appendedBytes = 0;
|
|
@@ -455,6 +522,8 @@ export class FluxClient {
|
|
|
455
522
|
this.firstAppendMs = 0;
|
|
456
523
|
this.retainedBytes = 0;
|
|
457
524
|
this.wasmMemoryBytes = 0;
|
|
525
|
+
this.lastContent = ""; // setContent baseline: the worker drops the parser here
|
|
526
|
+
this.contentDone = false;
|
|
458
527
|
// Same streamId + worker — the worker frees and lazily recreates the parser.
|
|
459
528
|
const pw = this.ensureAcquired();
|
|
460
529
|
this.pool.send(pw, { type: "reset", streamId: this.streamId });
|
|
@@ -481,6 +550,11 @@ export class FluxClient {
|
|
|
481
550
|
*/
|
|
482
551
|
reattach() {
|
|
483
552
|
if (this.attached) return;
|
|
553
|
+
// The prior destroy()→dispose dropped this stream's parser, so setContent's
|
|
554
|
+
// diff baseline is stale — clear it so the next setContent re-feeds the whole
|
|
555
|
+
// document (StrictMode dev double-mount on the SAME instance).
|
|
556
|
+
this.lastContent = "";
|
|
557
|
+
this.contentDone = false;
|
|
484
558
|
if (!this.pw) {
|
|
485
559
|
// Never acquired (e.g. constructed during SSR, first real mount on client).
|
|
486
560
|
// No prior pool slot to re-register; just mark attached. The next
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* client.finalize();
|
|
17
17
|
*/
|
|
18
18
|
export { FluxClient, FluxPool, getDefaultPool } from "./client";
|
|
19
|
-
export { FluxMarkdown } from "./react";
|
|
19
|
+
export { FluxMarkdown, useFluxStream, useFluxMarkdownString } from "./react";
|
|
20
20
|
export { highlight, supportedLangs } from "./hi";
|
|
21
21
|
export { htmlToReact, parseTrustedHtml } from "./html-to-react";
|
|
22
22
|
export type {
|
package/src/react.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
useSyncExternalStore,
|
|
9
9
|
type CSSProperties,
|
|
10
|
+
type ReactElement,
|
|
10
11
|
} from "react";
|
|
11
12
|
import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
|
|
12
13
|
import { FluxClient } from "./client";
|
|
@@ -209,6 +210,52 @@ export function useFluxStream(
|
|
|
209
210
|
return client;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the bridge for
|
|
215
|
+
* UIs that hold a streaming message as a single growing string prop (the common
|
|
216
|
+
* React shape) rather than as a stream. Pass the whole document-so-far on each
|
|
217
|
+
* render and {@link FluxClient.setContent} diffs it: a prefix-extension appends
|
|
218
|
+
* only the delta; any divergence (e.g. the finished text swapped for a
|
|
219
|
+
* re-processed final string) resets and reparses. Returns the owned client —
|
|
220
|
+
* pass it to `<FluxMarkdown client={…} />` (and read `outline()` etc.).
|
|
221
|
+
*
|
|
222
|
+
* Pass `streaming: false` once the content is final to finalize the stream and
|
|
223
|
+
* commit its last block (only then does a finished code fence highlight + show
|
|
224
|
+
* its copy button). If `streaming` is omitted or `true` the stream is left OPEN
|
|
225
|
+
* — right for a still-growing string, but a *complete static* string rendered as
|
|
226
|
+
* `useFluxMarkdownString(md)` keeps its last block in the streaming state until
|
|
227
|
+
* you pass `{ streaming: false }`. (Inferring "done" from an absent flag is
|
|
228
|
+
* deliberately avoided: it would re-finalize on every token for callers that
|
|
229
|
+
* grow the string without the flag — an O(n²) reparse trap.) The client is
|
|
230
|
+
* created once and destroyed on unmount; StrictMode's dev double-mount is handled
|
|
231
|
+
* (reattach re-feeds the document). For a true stream source
|
|
232
|
+
* (`Response` / `ReadableStream` / SSE generator) use {@link useFluxStream}
|
|
233
|
+
* instead — it avoids buffering the whole document as a string.
|
|
234
|
+
*/
|
|
235
|
+
export function useFluxMarkdownString(
|
|
236
|
+
content: string,
|
|
237
|
+
options?: { config?: ParserConfig; streaming?: boolean },
|
|
238
|
+
): FluxClient {
|
|
239
|
+
const [client] = useState(() => new FluxClient({ config: options?.config }));
|
|
240
|
+
|
|
241
|
+
// Own the client's pool attachment (StrictMode dev double-mount destroys on the
|
|
242
|
+
// simulated unmount then remounts the SAME instance; reattach re-registers and
|
|
243
|
+
// clears setContent's diff baseline so the document is re-fed). Destroy on the
|
|
244
|
+
// real unmount.
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
client.reattach();
|
|
247
|
+
return () => client.destroy();
|
|
248
|
+
}, [client]);
|
|
249
|
+
|
|
250
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
251
|
+
// so this stays correct whether `content` grows by a token or is swapped wholesale.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
client.setContent(content, { done: options?.streaming === false });
|
|
254
|
+
}, [client, content, options?.streaming]);
|
|
255
|
+
|
|
256
|
+
return client;
|
|
257
|
+
}
|
|
258
|
+
|
|
212
259
|
// Stream mode: own a client via the hook, then render the normal client path.
|
|
213
260
|
function FluxMarkdownFromStream(props: FluxMarkdownProps) {
|
|
214
261
|
const client = useFluxStream(props.stream, {
|
|
@@ -336,7 +383,10 @@ function componentInnerHtml(html: string, tag: string): string {
|
|
|
336
383
|
|
|
337
384
|
/** Convert a closed block's HTML to a React tree, memoized on html+components. */
|
|
338
385
|
function SafeHtml({ html, components }: { html: string; components: Components }) {
|
|
339
|
-
|
|
386
|
+
// `ReactElement` (not the global `JSX.Element`) so the source type-checks under
|
|
387
|
+
// both @types/react 18 and 19 — React 19 removed the global `JSX` namespace,
|
|
388
|
+
// and a consumer's `next build` type-checks this shipped source.
|
|
389
|
+
return useMemo(() => htmlToReact(html, components), [html, components]) as ReactElement;
|
|
340
390
|
}
|
|
341
391
|
|
|
342
392
|
// Per-kind off-screen size estimate for `contain-intrinsic-size` — keeps the
|
|
@@ -419,12 +469,20 @@ function renderBlockContent({
|
|
|
419
469
|
(block.open ? " flux-open" : "") +
|
|
420
470
|
(block.speculative ? " flux-speculative" : "");
|
|
421
471
|
|
|
422
|
-
// Tag-level overrides
|
|
423
|
-
//
|
|
424
|
-
|
|
472
|
+
// Tag-level / inline overrides apply to OPEN and speculative blocks too, not
|
|
473
|
+
// just settled ones: the streaming tail's HTML is always well-formed (the
|
|
474
|
+
// parser speculatively closes it), so a design-system renderer (Tailwind
|
|
475
|
+
// classes on p/ul/li, inline <a>/<code> overrides) stays styled mid-stream
|
|
476
|
+
// instead of only after a block commits. A supplied `sanitize` runs FIRST
|
|
477
|
+
// (same as the innerHTML path below), so overrides compose with sanitization on
|
|
478
|
+
// every block — closing the gap where a component-rendered block previously
|
|
479
|
+
// bypassed the user sanitizer. The no-`components` fast path is untouched
|
|
480
|
+
// (byte-identical innerHTML).
|
|
481
|
+
if (components) {
|
|
482
|
+
const safe = sanitize ? sanitize(block.html) : block.html;
|
|
425
483
|
return (
|
|
426
484
|
<div className={className}>
|
|
427
|
-
<SafeHtml html={
|
|
485
|
+
<SafeHtml html={safe} components={components} />
|
|
428
486
|
</div>
|
|
429
487
|
);
|
|
430
488
|
}
|
package/src/solid.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { onCleanup, onMount, type JSX } from "solid-js";
|
|
2
|
-
import
|
|
1
|
+
import { createEffect, onCleanup, onMount, type JSX } from "solid-js";
|
|
2
|
+
import { FluxClient } from "./client";
|
|
3
|
+
import type { ParserConfig } from "./types-core";
|
|
3
4
|
import { mountFluxMarkdown, type MountHandle, type MountOptions } from "./dom";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -73,3 +74,72 @@ export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
|
|
|
73
74
|
onMount(() => mountSolid(() => props, container, onCleanup));
|
|
74
75
|
return container;
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wire a controlled string to a freshly-constructed {@link FluxClient}, free of
|
|
80
|
+
* Solid's reactive runtime so it runs (and is tested) under any toolchain. The
|
|
81
|
+
* registrars are injected: the public {@link createFluxMarkdownString} passes
|
|
82
|
+
* Solid's real `createEffect` / `onCleanup`; tests pass hand-rolled stand-ins
|
|
83
|
+
* (mirroring how {@link mountSolid} takes `registerCleanup`).
|
|
84
|
+
*
|
|
85
|
+
* Ownership DIFFERS from {@link mountSolid}: this constructs the client and so
|
|
86
|
+
* `registerCleanup`s `client.destroy()` — it OWNS the worker/stream. `config` is
|
|
87
|
+
* read ONCE here (the constructor treats it as immutable); `getContent()` and
|
|
88
|
+
* `streaming` are read INSIDE the effect so the effect tracks them reactively.
|
|
89
|
+
*/
|
|
90
|
+
export function setupFluxMarkdownString(
|
|
91
|
+
getContent: () => string,
|
|
92
|
+
getOptions: (() => { config?: ParserConfig; streaming?: boolean }) | undefined,
|
|
93
|
+
registerEffect: (fn: () => void) => void,
|
|
94
|
+
registerCleanup: (fn: () => void) => void,
|
|
95
|
+
): FluxClient {
|
|
96
|
+
// One client per helper instance. Constructor is worker-free → SSR-safe; the
|
|
97
|
+
// worker is spawned lazily by the first setContent → append, which only runs
|
|
98
|
+
// inside the effect below. config is read once and is immutable thereafter.
|
|
99
|
+
const client = new FluxClient({ config: getOptions?.()?.config });
|
|
100
|
+
|
|
101
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
102
|
+
// so this is correct whether `content` grows by a token or is swapped wholesale.
|
|
103
|
+
// `streaming === false` (never `!streaming`) → only an explicit false finalizes;
|
|
104
|
+
// an absent/true flag leaves the stream open (inferring "done" from an absent
|
|
105
|
+
// flag would re-finalize on every token — an O(n²) reparse trap).
|
|
106
|
+
registerEffect(() => {
|
|
107
|
+
client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// This helper OWNS the client (unlike the client-based bindings above), so it
|
|
111
|
+
// destroys it on cleanup — freeing its pool slot.
|
|
112
|
+
registerCleanup(() => client.destroy());
|
|
113
|
+
|
|
114
|
+
return client;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the Solid
|
|
119
|
+
* analogue of React's `useFluxMarkdownString`, for UIs that hold a streaming
|
|
120
|
+
* message as a single growing string (a signal/memo) rather than as a stream.
|
|
121
|
+
* Pass an accessor for the whole document-so-far; on every change
|
|
122
|
+
* {@link FluxClient.setContent} diffs it and does the minimal work (a
|
|
123
|
+
* prefix-extension appends only the delta; any divergence resets and reparses).
|
|
124
|
+
*
|
|
125
|
+
* Pass `streaming: false` (via `getOptions`) once the content is final to
|
|
126
|
+
* finalize the stream and commit its last block (only then does a finished code
|
|
127
|
+
* fence highlight + show its copy button). If `streaming` is omitted or `true`
|
|
128
|
+
* the stream is left OPEN. `config` is read once at construction and is
|
|
129
|
+
* immutable, so it is not a change trigger.
|
|
130
|
+
*
|
|
131
|
+
* **Returns the owned client** — pass it to `<FluxMarkdown client={client} />`
|
|
132
|
+
* (and read `outline()` / `getMetrics()` off it). The client is constructed in
|
|
133
|
+
* the body (constructor is worker-free → SSR-safe) and destroyed on cleanup.
|
|
134
|
+
*
|
|
135
|
+
* SSR-safety: `setContent` is what spawns a Worker (via `append`), so it runs
|
|
136
|
+
* ONLY inside a `createEffect` — Solid does not run user effects during
|
|
137
|
+
* `renderToString`, so nothing touches a Worker on the server render path (the
|
|
138
|
+
* body only constructs the worker-free client).
|
|
139
|
+
*/
|
|
140
|
+
export function createFluxMarkdownString(
|
|
141
|
+
getContent: () => string,
|
|
142
|
+
getOptions?: () => { config?: ParserConfig; streaming?: boolean },
|
|
143
|
+
): FluxClient {
|
|
144
|
+
return setupFluxMarkdownString(getContent, getOptions, createEffect, onCleanup);
|
|
145
|
+
}
|
package/src/svelte.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ActionReturn } from "svelte/action";
|
|
2
|
-
import
|
|
2
|
+
import { FluxClient } from "./client";
|
|
3
|
+
import type { ParserConfig } from "./types-core";
|
|
3
4
|
import { mountFluxMarkdown, type DomComponents, type MountOptions } from "./dom";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -53,3 +54,101 @@ export function fluxMarkdown(
|
|
|
53
54
|
},
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Controlled-string sibling of {@link fluxMarkdown}: instead of taking a
|
|
60
|
+
* caller-owned client, this action OWNS a single {@link FluxClient} (constructed
|
|
61
|
+
* from `config`) and drives it from a CONTROLLED full string — the bridge for
|
|
62
|
+
* Svelte UIs that hold a streaming message as one growing `content` prop rather
|
|
63
|
+
* than feeding the client by hand. Each update passes the whole document-so-far
|
|
64
|
+
* and {@link FluxClient.setContent} diffs it: a prefix-extension appends only the
|
|
65
|
+
* delta; any divergence resets and reparses.
|
|
66
|
+
*
|
|
67
|
+
* ```svelte
|
|
68
|
+
* <div use:fluxMarkdownString={{ content, streaming: !done }} />
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* Pass `streaming: false` once the content is final to finalize the stream and
|
|
72
|
+
* commit its last block (only then does a finished code fence highlight + show
|
|
73
|
+
* its copy button). When `streaming` is omitted or `true` the stream is left
|
|
74
|
+
* OPEN — right for a still-growing string, but a *complete static* string keeps
|
|
75
|
+
* its last block in the streaming state until you pass `{ streaming: false }`.
|
|
76
|
+
* (Inferring "done" from an absent flag is deliberately avoided — it would
|
|
77
|
+
* re-finalize on every token and trip an O(n²) reparse.)
|
|
78
|
+
*
|
|
79
|
+
* SSR-safe by construction: a Svelte action runs ONLY in the browser, and the
|
|
80
|
+
* `FluxClient` constructor is worker-free — the first worker is spawned lazily by
|
|
81
|
+
* `setContent`, which only runs here (never during a server render).
|
|
82
|
+
*
|
|
83
|
+
* Lifecycle differs from {@link fluxMarkdown}: this action constructs the client
|
|
84
|
+
* once (a later `config` change is ignored, like a created-once instance) and
|
|
85
|
+
* `destroy()`s it on teardown — it OWNS the client. The mount-option reconcile
|
|
86
|
+
* (`components`/`sanitize`/`virtualize`/`stickToBottom`) matches `fluxMarkdown`,
|
|
87
|
+
* but the remount reuses the SAME client so its `setContent` diff baseline
|
|
88
|
+
* survives.
|
|
89
|
+
*/
|
|
90
|
+
export interface FluxMarkdownStringParams extends Omit<FluxMarkdownParams, "client"> {
|
|
91
|
+
/** The full document-so-far. Diffed against the prior value on every update. */
|
|
92
|
+
content: string;
|
|
93
|
+
/** Leave the stream open while true/omitted; `false` finalizes (commits the tail). */
|
|
94
|
+
streaming?: boolean;
|
|
95
|
+
/** Per-stream parser flags. Applied once at construction; later changes are ignored. */
|
|
96
|
+
config?: ParserConfig;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Strip the action-only inputs (`content`/`streaming`/`config`), leaving the
|
|
100
|
+
* fields {@link mountFluxMarkdown} reads — so they never leak into the mount. */
|
|
101
|
+
function mountOptionsOf(p: FluxMarkdownStringParams): Omit<FluxMarkdownParams, "client"> {
|
|
102
|
+
const { content: _c, streaming: _s, config: _cfg, ...rest } = p;
|
|
103
|
+
void _c;
|
|
104
|
+
void _s;
|
|
105
|
+
void _cfg;
|
|
106
|
+
return rest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function fluxMarkdownString(
|
|
110
|
+
node: HTMLElement,
|
|
111
|
+
params: FluxMarkdownStringParams,
|
|
112
|
+
): ActionReturn<FluxMarkdownStringParams> {
|
|
113
|
+
// This action OWNS the client — construct it once from `config` (a later
|
|
114
|
+
// `config` change is ignored, mirroring the created-once React hook). The
|
|
115
|
+
// content/streaming diff baseline lives INSIDE the client (setContent), so we
|
|
116
|
+
// keep no outer copy; only the mount-option fields are tracked for the remount
|
|
117
|
+
// comparison.
|
|
118
|
+
let options = mountOptionsOf(params);
|
|
119
|
+
const client = new FluxClient({ config: params.config });
|
|
120
|
+
let handle = mountFluxMarkdown(client, node, options as MountOptions);
|
|
121
|
+
// First worker-bound op: spawns the lazy Worker — browser-only, never SSR.
|
|
122
|
+
client.setContent(params.content, { done: params.streaming === false });
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
update(next: FluxMarkdownStringParams) {
|
|
126
|
+
// Content/streaming are the primary changing inputs, so reconcile them on
|
|
127
|
+
// EVERY update — setContent self-no-ops when the string is unchanged, so
|
|
128
|
+
// this is cheap. (Unlike fluxMarkdown, we cannot early-return: that would
|
|
129
|
+
// swallow content updates.)
|
|
130
|
+
client.setContent(next.content, { done: next.streaming === false });
|
|
131
|
+
|
|
132
|
+
// Then reconcile mount options exactly like fluxMarkdown: remount only when
|
|
133
|
+
// a field the renderer reads actually changed identity, and reuse the SAME
|
|
134
|
+
// client so its setContent diff baseline (lastContent) survives the remount.
|
|
135
|
+
if (
|
|
136
|
+
next.components === options.components &&
|
|
137
|
+
next.sanitize === options.sanitize &&
|
|
138
|
+
next.virtualize === options.virtualize &&
|
|
139
|
+
next.stickToBottom === options.stickToBottom
|
|
140
|
+
) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
handle.destroy();
|
|
144
|
+
options = mountOptionsOf(next);
|
|
145
|
+
handle = mountFluxMarkdown(client, node, options as MountOptions);
|
|
146
|
+
},
|
|
147
|
+
destroy() {
|
|
148
|
+
// This action OWNS the client (unlike fluxMarkdown) — tear down the mount
|
|
149
|
+
// AND destroy the client so its pool slot is freed.
|
|
150
|
+
handle.destroy();
|
|
151
|
+
client.destroy();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/vue.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineComponent, h, onMounted, onUnmounted, ref, watch } from "vue";
|
|
2
2
|
import type { PropType, Ref } from "vue";
|
|
3
|
-
import
|
|
3
|
+
import { FluxClient } from "./client";
|
|
4
|
+
import type { ParserConfig } from "./types-core";
|
|
4
5
|
import { mountFluxMarkdown, type DomComponents, type MountHandle, type MountOptions } from "./dom";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -98,3 +99,59 @@ export const FluxMarkdown = defineComponent({
|
|
|
98
99
|
return () => h("div", { ref: container });
|
|
99
100
|
},
|
|
100
101
|
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the Vue analogue
|
|
105
|
+
* of React's `useFluxMarkdownString`, for UIs that hold a streaming message as a
|
|
106
|
+
* single growing string (a `ref`/computed) rather than as a stream. Pass a getter
|
|
107
|
+
* for the whole document-so-far; on every change {@link FluxClient.setContent}
|
|
108
|
+
* diffs it and does the minimal work (prefix-extension appends only the delta;
|
|
109
|
+
* any divergence resets and reparses).
|
|
110
|
+
*
|
|
111
|
+
* Pass `streaming: false` (via `getOptions`) once the content is final to
|
|
112
|
+
* finalize the stream and commit its last block. If `streaming` is omitted or
|
|
113
|
+
* `true` the stream is left OPEN — inferring "done" from an absent flag is
|
|
114
|
+
* deliberately avoided (it would re-finalize on every token for callers that
|
|
115
|
+
* grow the string without the flag — an O(n²) reparse trap). `config` is read
|
|
116
|
+
* once at construction and is immutable thereafter, so it is not a change
|
|
117
|
+
* trigger.
|
|
118
|
+
*
|
|
119
|
+
* **Returns the owned client** — a deliberate divergence from {@link useFluxMarkdown}
|
|
120
|
+
* (which returns `{ container }`). Mirroring React's hook, this composes with the
|
|
121
|
+
* component as `<FluxMarkdown :client="client" />` (and lets you read
|
|
122
|
+
* `outline()` / `getMetrics()` off it). The client is created in the composable
|
|
123
|
+
* body (constructor is worker-free → SSR-safe) and destroyed on unmount.
|
|
124
|
+
*
|
|
125
|
+
* SSR-safety: `setContent` is what spawns a Worker (via `append`), so it is
|
|
126
|
+
* called ONLY in `onMounted` and a NON-immediate `watch` — never during the
|
|
127
|
+
* server render path (`setup` constructs the client but neither lifecycle hook
|
|
128
|
+
* nor the non-immediate watch fires on the server).
|
|
129
|
+
*/
|
|
130
|
+
export function useFluxMarkdownString(
|
|
131
|
+
getContent: () => string,
|
|
132
|
+
getOptions?: () => { config?: ParserConfig; streaming?: boolean },
|
|
133
|
+
): FluxClient {
|
|
134
|
+
// One client per composable instance. Constructor is worker-free, so this is
|
|
135
|
+
// safe to run in setup() during SSR; config is read once and is immutable.
|
|
136
|
+
const client = new FluxClient({ config: getOptions?.()?.config });
|
|
137
|
+
|
|
138
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
139
|
+
// so this is correct whether `content` grows by a token or is swapped wholesale.
|
|
140
|
+
// `streaming === false` (never `!streaming`) → only an explicit false finalizes;
|
|
141
|
+
// an absent/true flag leaves the stream open.
|
|
142
|
+
const apply = (): void => {
|
|
143
|
+
client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Initial feed + every change. NOT { immediate: true }: an immediate watch runs
|
|
147
|
+
// in setup() — i.e. during SSR — and would spawn a Worker on the server. The
|
|
148
|
+
// initial feed is onMounted (client-only); the watch covers later changes.
|
|
149
|
+
onMounted(apply);
|
|
150
|
+
watch([getContent, () => getOptions?.()?.streaming], apply);
|
|
151
|
+
|
|
152
|
+
// This composable OWNS the client (unlike useFluxMarkdown, which takes one), so
|
|
153
|
+
// it destroys it here. Vue auto-stops the watcher on unmount.
|
|
154
|
+
onUnmounted(() => client.destroy());
|
|
155
|
+
|
|
156
|
+
return client;
|
|
157
|
+
}
|
|
Binary file
|
package/src/wasm/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { FromWorker, ParserConfig, Patch, ToWorker } from "./types-core";
|
|
2
|
+
|
|
3
|
+
/** The slice of `FluxParser` the worker drives — narrowed to an interface so the
|
|
4
|
+
* message/readiness state machine is unit-testable with a fake parser, no WASM.
|
|
5
|
+
* (Same testability move as {@link FluxPool} taking an injected worker factory.) */
|
|
6
|
+
export interface ParserLike {
|
|
7
|
+
append(chunk: string): Patch;
|
|
8
|
+
finalize(): Patch;
|
|
9
|
+
free(): void;
|
|
10
|
+
retainedBytes(): number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Dependencies injected into {@link WorkerCore}, isolating it from the worker
|
|
14
|
+
* globals (`self`, `queueMicrotask`) and the WASM module so it can be tested. */
|
|
15
|
+
export interface WorkerCoreDeps {
|
|
16
|
+
/** Create + configure a parser for a stream (prod: `new FluxParser()` + setX). */
|
|
17
|
+
makeParser(config: ParserConfig | undefined): ParserLike;
|
|
18
|
+
/** Post a message to the main thread (prod: `self.postMessage`). */
|
|
19
|
+
post(msg: FromWorker): void;
|
|
20
|
+
/** Current WASM heap size in bytes, reported on each patch (0 if unknown). */
|
|
21
|
+
memBytes(): number;
|
|
22
|
+
/** Defer a flush to a future microtask (prod: `queueMicrotask`). */
|
|
23
|
+
schedule(fn: () => void): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The worker's message reducer + WASM-readiness gate, extracted from the worker
|
|
28
|
+
* shell so its trickiest invariant is testable without a real Worker or WASM.
|
|
29
|
+
*
|
|
30
|
+
* **The invariant:** WASM `init()` is async, and the client does NOT wait for
|
|
31
|
+
* readiness before appending — so chunks can arrive first. A parser must never
|
|
32
|
+
* be constructed before init resolves (`new FluxParser()` against an
|
|
33
|
+
* uninitialized module throws `fluxparser_new of undefined` and silently drops
|
|
34
|
+
* that chunk). So while `ready` is false, appends only accumulate in `pending`
|
|
35
|
+
* (scheduleFlush is a no-op) and `finalize` is deferred; {@link markReady}
|
|
36
|
+
* drains both — appends first (creating each parser + applying buffered text),
|
|
37
|
+
* then any deferred finalize — once init has completed.
|
|
38
|
+
*/
|
|
39
|
+
export class WorkerCore {
|
|
40
|
+
// One parser per stream id; WASM is loaded once and shared by all of them.
|
|
41
|
+
private parsers = new Map<number, ParserLike>();
|
|
42
|
+
private config = new Map<number, ParserConfig>();
|
|
43
|
+
private pending = new Map<number, string>();
|
|
44
|
+
private totalAppended = new Map<number, number>();
|
|
45
|
+
private finalizePending = new Set<number>();
|
|
46
|
+
private flushScheduled = false;
|
|
47
|
+
private ready = false;
|
|
48
|
+
|
|
49
|
+
constructor(private deps: WorkerCoreDeps) {}
|
|
50
|
+
|
|
51
|
+
/** Handle one message from the main thread (append/finalize/reset/dispose). */
|
|
52
|
+
handle(msg: ToWorker): void {
|
|
53
|
+
const id = msg.streamId;
|
|
54
|
+
// Stash any per-stream config carried on the first message (FIFO guarantees
|
|
55
|
+
// it arrives before the parser is created in flush/finalize).
|
|
56
|
+
if ((msg.type === "append" || msg.type === "finalize") && msg.config) {
|
|
57
|
+
this.config.set(id, msg.config);
|
|
58
|
+
}
|
|
59
|
+
switch (msg.type) {
|
|
60
|
+
case "append":
|
|
61
|
+
this.pending.set(id, (this.pending.get(id) ?? "") + msg.chunk);
|
|
62
|
+
this.scheduleFlush();
|
|
63
|
+
break;
|
|
64
|
+
case "finalize":
|
|
65
|
+
// Before WASM is ready, defer: markReady() finalizes it after init (the
|
|
66
|
+
// buffered input is drained first). Otherwise finalize now.
|
|
67
|
+
if (!this.ready) this.finalizePending.add(id);
|
|
68
|
+
else this.doFinalize(id);
|
|
69
|
+
break;
|
|
70
|
+
case "reset":
|
|
71
|
+
// Free and recreate lazily on the next append — same stream id, **same
|
|
72
|
+
// config** (kept). The client resets its local state synchronously.
|
|
73
|
+
this.parsers.get(id)?.free();
|
|
74
|
+
this.parsers.delete(id);
|
|
75
|
+
this.pending.delete(id);
|
|
76
|
+
this.finalizePending.delete(id); // a reset cancels a not-yet-run early finalize
|
|
77
|
+
this.totalAppended.delete(id);
|
|
78
|
+
break;
|
|
79
|
+
case "dispose":
|
|
80
|
+
this.dispose(id);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Called once WASM init resolves: open the gate and drain what was buffered. */
|
|
86
|
+
markReady(): void {
|
|
87
|
+
this.ready = true;
|
|
88
|
+
this.deps.post({ type: "ready" });
|
|
89
|
+
// Order matters: flush appends first (creating each parser + applying
|
|
90
|
+
// buffered text), then finalize any stream that already requested it.
|
|
91
|
+
if (this.pending.size > 0) this.flush();
|
|
92
|
+
if (this.finalizePending.size > 0) {
|
|
93
|
+
for (const id of this.finalizePending) this.doFinalize(id);
|
|
94
|
+
this.finalizePending.clear();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private getOrCreate(streamId: number): ParserLike {
|
|
99
|
+
let p = this.parsers.get(streamId);
|
|
100
|
+
if (!p) {
|
|
101
|
+
p = this.deps.makeParser(this.config.get(streamId));
|
|
102
|
+
this.parsers.set(streamId, p);
|
|
103
|
+
}
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private dispose(streamId: number): void {
|
|
108
|
+
this.parsers.get(streamId)?.free();
|
|
109
|
+
this.parsers.delete(streamId);
|
|
110
|
+
this.config.delete(streamId);
|
|
111
|
+
this.pending.delete(streamId);
|
|
112
|
+
this.finalizePending.delete(streamId);
|
|
113
|
+
this.totalAppended.delete(streamId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private emitPatch(streamId: number, patch: Patch, parser: ParserLike, parseMicros: number): void {
|
|
117
|
+
this.deps.post({
|
|
118
|
+
type: "patch",
|
|
119
|
+
streamId,
|
|
120
|
+
patch,
|
|
121
|
+
appendedBytes: this.totalAppended.get(streamId) ?? 0,
|
|
122
|
+
parseMicros,
|
|
123
|
+
retainedBytes: parser.retainedBytes(),
|
|
124
|
+
wasmMemoryBytes: this.deps.memBytes(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private scheduleFlush(): void {
|
|
129
|
+
if (this.flushScheduled || !this.ready) return; // before ready, input just accumulates in `pending`
|
|
130
|
+
this.flushScheduled = true;
|
|
131
|
+
this.deps.schedule(() => this.flush());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private flush(): void {
|
|
135
|
+
this.flushScheduled = false;
|
|
136
|
+
if (!this.ready || this.pending.size === 0) return; // buffer stays put until WASM is ready
|
|
137
|
+
// Process every stream with buffered input this microtask.
|
|
138
|
+
for (const [streamId, chunk] of this.pending) {
|
|
139
|
+
this.pending.delete(streamId);
|
|
140
|
+
if (chunk.length === 0) continue;
|
|
141
|
+
const t0 = performance.now();
|
|
142
|
+
try {
|
|
143
|
+
// getOrCreate (→ makeParser) is inside the try: with `ready` gating it
|
|
144
|
+
// can't hit the init race, but any other construction failure becomes a
|
|
145
|
+
// posted error rather than an uncaught exception that kills the worker.
|
|
146
|
+
const parser = this.getOrCreate(streamId);
|
|
147
|
+
const patch = parser.append(chunk) as Patch;
|
|
148
|
+
const dt = (performance.now() - t0) * 1000;
|
|
149
|
+
this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + chunk.length);
|
|
150
|
+
this.emitPatch(streamId, patch, parser, dt);
|
|
151
|
+
} catch (e: unknown) {
|
|
152
|
+
this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Drain a stream's buffered input (if any), then finalize its parser. Shared by
|
|
158
|
+
// the `finalize` message path and markReady()'s post-ready drain.
|
|
159
|
+
private doFinalize(streamId: number): void {
|
|
160
|
+
const buffered = this.pending.get(streamId);
|
|
161
|
+
this.pending.delete(streamId);
|
|
162
|
+
try {
|
|
163
|
+
const parser = this.getOrCreate(streamId);
|
|
164
|
+
if (buffered && buffered.length > 0) {
|
|
165
|
+
parser.append(buffered);
|
|
166
|
+
this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + buffered.length);
|
|
167
|
+
}
|
|
168
|
+
const patch = parser.finalize() as Patch;
|
|
169
|
+
this.emitPatch(streamId, patch, parser, 0);
|
|
170
|
+
} catch (e: unknown) {
|
|
171
|
+
this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -1,52 +1,27 @@
|
|
|
1
1
|
/// <reference lib="webworker" />
|
|
2
2
|
import init, { FluxParser } from "./wasm/flux_md_core.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ParserConfig } from "./types";
|
|
4
|
+
import { WorkerCore, type ParserLike } from "./worker-core";
|
|
4
5
|
|
|
5
6
|
// Resolve the WASM asset with the *web-standard* `new URL(asset,
|
|
6
7
|
// import.meta.url)` pattern (not Vite's `?url` suffix), so the package works in
|
|
7
|
-
// any bundler with asset-module support — Vite, webpack 5, Rollup, Parcel
|
|
8
|
-
// wasm-bindgen's init() fetches a URL instance
|
|
8
|
+
// any bundler with asset-module support — Vite, webpack 5, Rollup, Parcel, and
|
|
9
|
+
// Next.js (Turbopack/webpack). wasm-bindgen's init() fetches a URL instance.
|
|
9
10
|
const wasmUrl = new URL("./wasm/flux_md_core_bg.wasm", import.meta.url);
|
|
10
11
|
|
|
11
|
-
// One worker multiplexes many streams: a parser per stream id (the worker
|
|
12
|
-
// pool). WASM is loaded once for the whole worker, shared by every parser.
|
|
13
|
-
const parsers = new Map<number, FluxParser>();
|
|
14
|
-
const config = new Map<number, ParserConfig>();
|
|
15
|
-
const pending = new Map<number, string>();
|
|
16
|
-
const totalAppended = new Map<number, number>();
|
|
17
|
-
let flushScheduled = false;
|
|
18
|
-
let wasmExports: any = null;
|
|
19
|
-
|
|
20
12
|
const ctx: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope;
|
|
21
13
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function setup() {
|
|
27
|
-
// init() returns the wasm-bindgen instance; capture its `.memory` export so
|
|
28
|
-
// we can report WASM-side memory usage on every patch. No parser yet — they
|
|
29
|
-
// are created per stream, on demand.
|
|
30
|
-
try {
|
|
31
|
-
wasmExports = await init({ module_or_path: wasmUrl });
|
|
32
|
-
post({ type: "ready" });
|
|
33
|
-
} catch (e: unknown) {
|
|
34
|
-
// WASM failed to load/instantiate: this worker can never parse anything.
|
|
35
|
-
// Report it so the pool rejects whenReady() (rather than hanging forever)
|
|
36
|
-
// and clients' onError fires. streamId is irrelevant for a worker-level
|
|
37
|
-
// failure — the pool routes a fatal error to every stream it hosts.
|
|
38
|
-
post({ type: "error", streamId: -1, message: e instanceof Error ? e.message : String(e), fatal: true });
|
|
39
|
-
}
|
|
40
|
-
}
|
|
14
|
+
// Captured from init() so we can report WASM-side memory usage on each patch.
|
|
15
|
+
let wasmExports: { memory?: { buffer?: ArrayBufferLike } } | null = null;
|
|
41
16
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
17
|
+
// The message/readiness state machine lives in WorkerCore (testable without
|
|
18
|
+
// WASM); this shell injects the WASM-specific dependencies.
|
|
19
|
+
const core = new WorkerCore({
|
|
20
|
+
// Create + configure a parser for a stream. Omitted config fields fall back to
|
|
21
|
+
// the library defaults — autolinks + alerts on (LLM output is full of bare
|
|
22
|
+
// URLs and `> [!NOTE]` blocks), raw HTML escaped, footnotes/math off.
|
|
23
|
+
makeParser(c: ParserConfig | undefined): ParserLike {
|
|
24
|
+
const p = new FluxParser();
|
|
50
25
|
p.setGfmAutolinks(c?.gfmAutolinks ?? true);
|
|
51
26
|
p.setGfmAlerts(c?.gfmAlerts ?? true);
|
|
52
27
|
p.setGfmFootnotes(c?.gfmFootnotes ?? false);
|
|
@@ -56,107 +31,35 @@ function getOrCreate(streamId: number): FluxParser {
|
|
|
56
31
|
p.setUnsafeHtml(c?.unsafeHtml ?? false);
|
|
57
32
|
p.setComponentTags(c?.componentTags ?? []);
|
|
58
33
|
p.setBlockData(c?.blockData ?? false);
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
function dispose(streamId: number) {
|
|
65
|
-
parsers.get(streamId)?.free();
|
|
66
|
-
parsers.delete(streamId);
|
|
67
|
-
config.delete(streamId);
|
|
68
|
-
pending.delete(streamId);
|
|
69
|
-
totalAppended.delete(streamId);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function wasmMemBytes(): number {
|
|
73
|
-
try {
|
|
74
|
-
return (wasmExports?.memory?.buffer?.byteLength as number) ?? 0;
|
|
75
|
-
} catch {
|
|
76
|
-
return 0;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function emitPatch(streamId: number, patch: Patch, parser: FluxParser, parseMicros: number) {
|
|
81
|
-
post({
|
|
82
|
-
type: "patch",
|
|
83
|
-
streamId,
|
|
84
|
-
patch,
|
|
85
|
-
appendedBytes: totalAppended.get(streamId) ?? 0,
|
|
86
|
-
parseMicros,
|
|
87
|
-
retainedBytes: parser.retainedBytes(),
|
|
88
|
-
wasmMemoryBytes: wasmMemBytes(),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function flush() {
|
|
93
|
-
flushScheduled = false;
|
|
94
|
-
if (pending.size === 0) return;
|
|
95
|
-
// Process every stream with buffered input this microtask.
|
|
96
|
-
for (const [streamId, chunk] of pending) {
|
|
97
|
-
pending.delete(streamId);
|
|
98
|
-
if (chunk.length === 0) continue;
|
|
99
|
-
const parser = getOrCreate(streamId);
|
|
100
|
-
const t0 = performance.now();
|
|
34
|
+
return p;
|
|
35
|
+
},
|
|
36
|
+
post: (msg) => ctx.postMessage(msg),
|
|
37
|
+
memBytes: () => {
|
|
101
38
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
emitPatch(streamId, patch, parser, dt);
|
|
106
|
-
} catch (e: unknown) {
|
|
107
|
-
post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
|
|
39
|
+
return (wasmExports?.memory?.buffer?.byteLength as number) ?? 0;
|
|
40
|
+
} catch {
|
|
41
|
+
return 0;
|
|
108
42
|
}
|
|
109
|
-
}
|
|
110
|
-
|
|
43
|
+
},
|
|
44
|
+
schedule: (fn) => queueMicrotask(fn),
|
|
45
|
+
});
|
|
111
46
|
|
|
112
|
-
|
|
113
|
-
if (flushScheduled) return;
|
|
114
|
-
flushScheduled = true;
|
|
115
|
-
queueMicrotask(flush);
|
|
116
|
-
}
|
|
47
|
+
ctx.addEventListener("message", (ev: MessageEvent) => core.handle(ev.data));
|
|
117
48
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
case "finalize": {
|
|
132
|
-
// Drain any buffered input for this stream, then finalize.
|
|
133
|
-
const buffered = pending.get(id);
|
|
134
|
-
pending.delete(id);
|
|
135
|
-
const parser = getOrCreate(id);
|
|
136
|
-
try {
|
|
137
|
-
if (buffered && buffered.length > 0) {
|
|
138
|
-
parser.append(buffered);
|
|
139
|
-
totalAppended.set(id, (totalAppended.get(id) ?? 0) + buffered.length);
|
|
140
|
-
}
|
|
141
|
-
const patch = parser.finalize() as Patch;
|
|
142
|
-
emitPatch(id, patch, parser, 0);
|
|
143
|
-
} catch (e: unknown) {
|
|
144
|
-
post({ type: "error", streamId: id, message: e instanceof Error ? e.message : String(e) });
|
|
145
|
-
}
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
case "reset":
|
|
149
|
-
// Free and recreate lazily on the next append — same stream id, **same
|
|
150
|
-
// config** (kept). The client resets its local state synchronously.
|
|
151
|
-
parsers.get(id)?.free();
|
|
152
|
-
parsers.delete(id);
|
|
153
|
-
pending.delete(id);
|
|
154
|
-
totalAppended.delete(id);
|
|
155
|
-
break;
|
|
156
|
-
case "dispose":
|
|
157
|
-
dispose(id);
|
|
158
|
-
break;
|
|
49
|
+
async function setup() {
|
|
50
|
+
// init() returns the wasm-bindgen instance; capture its `.memory` export for
|
|
51
|
+
// the per-patch memory metric. No parser yet — they are created per stream,
|
|
52
|
+
// on demand, only after markReady() opens the gate.
|
|
53
|
+
try {
|
|
54
|
+
wasmExports = await init({ module_or_path: wasmUrl });
|
|
55
|
+
core.markReady();
|
|
56
|
+
} catch (e: unknown) {
|
|
57
|
+
// WASM failed to load/instantiate: this worker can never parse anything.
|
|
58
|
+
// Report it so the pool rejects whenReady() (rather than hanging forever)
|
|
59
|
+
// and clients' onError fires. streamId is irrelevant for a worker-level
|
|
60
|
+
// failure — the pool routes a fatal error to every stream it hosts.
|
|
61
|
+
ctx.postMessage({ type: "error", streamId: -1, message: e instanceof Error ? e.message : String(e), fatal: true });
|
|
159
62
|
}
|
|
160
|
-
}
|
|
63
|
+
}
|
|
161
64
|
|
|
162
65
|
setup();
|