flux-md 0.11.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/src/styles.css ADDED
@@ -0,0 +1,182 @@
1
+ /*
2
+ * flux-md — optional default theme.
3
+ *
4
+ * import "flux-md/styles.css";
5
+ *
6
+ * Opt-in: import it for good-looking output out of the box (including the
7
+ * built-in syntax highlighter's colors); skip it to bring your own CSS — the
8
+ * rendered HTML is identical either way. Everything is scoped to `.flux-md`
9
+ * (the renderer's root) and driven by CSS custom properties, so you re-theme by
10
+ * overriding a few `--flux-*` vars rather than rewriting selectors.
11
+ *
12
+ * Light by default; dark automatically via `prefers-color-scheme`. Force a mode
13
+ * with `<div class="flux-md flux-dark">` or `flux-light`.
14
+ */
15
+
16
+ .flux-md {
17
+ /* surfaces + text (light) */
18
+ --flux-fg: #1f2328;
19
+ --flux-fg-muted: #59636e;
20
+ --flux-fg-faint: #818b98;
21
+ --flux-border: #d1d9e0;
22
+ --flux-bg-code: #f6f8fa;
23
+ --flux-bg-inline: rgba(129, 139, 152, 0.16);
24
+ --flux-bg-quote: rgba(129, 139, 152, 0.08);
25
+ --flux-accent: #0969da;
26
+ /* syntax tokens (light) */
27
+ --flux-t-kw: #cf222e;
28
+ --flux-t-str: #0a3069;
29
+ --flux-t-num: #0550ae;
30
+ --flux-t-com: #59636e;
31
+ --flux-t-fn: #6639ba;
32
+ --flux-t-ty: #953800;
33
+ --flux-t-mac: #1f6feb;
34
+ --flux-t-attr: #116329;
35
+ --flux-t-tag: #116329;
36
+ --flux-t-var: #953800;
37
+ --flux-t-pun: var(--flux-fg);
38
+ /* sizing */
39
+ --flux-radius: 6px;
40
+ --flux-gap: 16px;
41
+
42
+ color: var(--flux-fg);
43
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
44
+ line-height: 1.6;
45
+ font-size: 1rem;
46
+ word-wrap: break-word;
47
+ overflow-wrap: anywhere;
48
+ }
49
+
50
+ /* Dark — automatic, and as an explicit `.flux-dark` escape hatch. */
51
+ @media (prefers-color-scheme: dark) {
52
+ .flux-md:not(.flux-light) {
53
+ --flux-fg: #e6edf3;
54
+ --flux-fg-muted: #9198a1;
55
+ --flux-fg-faint: #6e7681;
56
+ --flux-border: #3d444d;
57
+ --flux-bg-code: #151b23;
58
+ --flux-bg-inline: rgba(101, 108, 118, 0.2);
59
+ --flux-bg-quote: rgba(101, 108, 118, 0.1);
60
+ --flux-accent: #4493f8;
61
+ --flux-t-kw: #ff7b72;
62
+ --flux-t-str: #a5d6ff;
63
+ --flux-t-num: #79c0ff;
64
+ --flux-t-com: #8b949e;
65
+ --flux-t-fn: #d2a8ff;
66
+ --flux-t-ty: #ffa657;
67
+ --flux-t-mac: #79c0ff;
68
+ --flux-t-attr: #7ee787;
69
+ --flux-t-tag: #7ee787;
70
+ --flux-t-var: #ffa657;
71
+ }
72
+ }
73
+ .flux-md.flux-dark {
74
+ --flux-fg: #e6edf3;
75
+ --flux-fg-muted: #9198a1;
76
+ --flux-fg-faint: #6e7681;
77
+ --flux-border: #3d444d;
78
+ --flux-bg-code: #151b23;
79
+ --flux-bg-inline: rgba(101, 108, 118, 0.2);
80
+ --flux-bg-quote: rgba(101, 108, 118, 0.1);
81
+ --flux-accent: #4493f8;
82
+ --flux-t-kw: #ff7b72;
83
+ --flux-t-str: #a5d6ff;
84
+ --flux-t-num: #79c0ff;
85
+ --flux-t-com: #8b949e;
86
+ --flux-t-fn: #d2a8ff;
87
+ --flux-t-ty: #ffa657;
88
+ --flux-t-mac: #79c0ff;
89
+ --flux-t-attr: #7ee787;
90
+ --flux-t-tag: #7ee787;
91
+ --flux-t-var: #ffa657;
92
+ }
93
+
94
+ /* ---- block rhythm ---------------------------------------------------------- */
95
+ .flux-md > * { margin: 0 0 var(--flux-gap) 0; }
96
+ .flux-md > *:last-child { margin-bottom: 0; }
97
+
98
+ /* ---- headings -------------------------------------------------------------- */
99
+ .flux-md h1, .flux-md h2, .flux-md h3,
100
+ .flux-md h4, .flux-md h5, .flux-md h6 {
101
+ font-weight: 600;
102
+ line-height: 1.25;
103
+ margin: 24px 0 var(--flux-gap);
104
+ }
105
+ .flux-md h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid var(--flux-border); }
106
+ .flux-md h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid var(--flux-border); }
107
+ .flux-md h3 { font-size: 1.25em; }
108
+ .flux-md h4 { font-size: 1em; }
109
+ .flux-md h5 { font-size: 0.875em; }
110
+ .flux-md h6 { font-size: 0.85em; color: var(--flux-fg-muted); }
111
+ .flux-md > h1:first-child, .flux-md > h2:first-child, .flux-md > h3:first-child { margin-top: 0; }
112
+
113
+ /* ---- inline ---------------------------------------------------------------- */
114
+ .flux-md a { color: var(--flux-accent); text-decoration: none; }
115
+ .flux-md a:hover { text-decoration: underline; }
116
+ .flux-md strong { font-weight: 600; }
117
+ .flux-md em { font-style: italic; }
118
+ .flux-md del { color: var(--flux-fg-muted); }
119
+ .flux-md img { max-width: 100%; height: auto; }
120
+
121
+ /* ---- lists ----------------------------------------------------------------- */
122
+ .flux-md ul, .flux-md ol { padding-left: 2em; }
123
+ .flux-md li { margin: 0.25em 0; }
124
+ .flux-md li > ul, .flux-md li > ol { margin: 0.25em 0; }
125
+ .flux-md li::marker { color: var(--flux-fg-faint); }
126
+ .flux-md input[type="checkbox"] { margin: 0 0.4em 0 0; }
127
+
128
+ /* ---- blockquote + alerts --------------------------------------------------- */
129
+ .flux-md blockquote {
130
+ margin: 0 0 var(--flux-gap);
131
+ padding: 0.4em 1em;
132
+ color: var(--flux-fg-muted);
133
+ border-left: 0.25em solid var(--flux-border);
134
+ background: var(--flux-bg-quote);
135
+ }
136
+ .flux-md blockquote > :last-child { margin-bottom: 0; }
137
+
138
+ /* ---- tables ---------------------------------------------------------------- */
139
+ .flux-md table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; }
140
+ .flux-md th, .flux-md td { padding: 6px 13px; border: 1px solid var(--flux-border); }
141
+ .flux-md th { font-weight: 600; background: var(--flux-bg-code); }
142
+ .flux-md tr:nth-child(2n) td { background: var(--flux-bg-quote); }
143
+
144
+ /* ---- code ------------------------------------------------------------------ */
145
+ .flux-md code, .flux-md pre {
146
+ font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
147
+ font-size: 0.9em;
148
+ }
149
+ .flux-md :not(pre) > code {
150
+ padding: 0.2em 0.4em;
151
+ border-radius: var(--flux-radius);
152
+ background: var(--flux-bg-inline);
153
+ }
154
+ .flux-md pre {
155
+ padding: 14px 16px;
156
+ overflow-x: auto;
157
+ border-radius: var(--flux-radius);
158
+ background: var(--flux-bg-code);
159
+ line-height: 1.5;
160
+ }
161
+ .flux-md pre code { padding: 0; background: none; border: 0; }
162
+
163
+ /* ---- rule ------------------------------------------------------------------ */
164
+ .flux-md hr { height: 1px; border: 0; background: var(--flux-border); margin: 24px 0; }
165
+
166
+ /* ---- syntax highlighter (the built-in `highlight()` token spans) ----------- */
167
+ .flux-md .t-kw { color: var(--flux-t-kw); }
168
+ .flux-md .t-str,
169
+ .flux-md .t-rx { color: var(--flux-t-str); }
170
+ .flux-md .t-num,
171
+ .flux-md .t-lt { color: var(--flux-t-num); }
172
+ .flux-md .t-com { color: var(--flux-t-com); font-style: italic; }
173
+ .flux-md .t-fn { color: var(--flux-t-fn); }
174
+ .flux-md .t-ty { color: var(--flux-t-ty); }
175
+ .flux-md .t-mac,
176
+ .flux-md .t-dec { color: var(--flux-t-mac); }
177
+ .flux-md .t-attr,
178
+ .flux-md .t-sel { color: var(--flux-t-attr); }
179
+ .flux-md .t-tag { color: var(--flux-t-tag); }
180
+ .flux-md .t-var { color: var(--flux-t-var); }
181
+ .flux-md .t-pun { color: var(--flux-t-pun); }
182
+ .flux-md .t-txt { color: inherit; }
package/src/svelte.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ActionReturn } from "svelte/action";
2
- import type { FluxClient } from "./client";
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 type { FluxClient } from "./client";
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
@@ -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.11.0",
5
+ "version": "0.13.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",
@@ -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
+ }