flux-md 0.16.2 → 0.17.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 +70 -39
- package/dist/block-props.d.ts +18 -0
- package/dist/block-props.js +75 -0
- package/dist/client.d.ts +310 -0
- package/{src/client.ts → dist/client.js} +123 -319
- package/dist/dom.d.ts +110 -0
- package/dist/dom.js +576 -0
- package/dist/element.d.ts +20 -0
- package/dist/element.js +287 -0
- package/dist/hi.d.ts +12 -0
- package/{src/hi.ts → dist/hi.js} +42 -74
- package/dist/html-to-react.d.ts +40 -0
- package/dist/html-to-react.js +344 -0
- package/{src/index.ts → dist/index.d.ts} +5 -25
- package/dist/index.js +16 -0
- package/dist/morph.d.ts +28 -0
- package/dist/morph.js +166 -0
- package/dist/react.d.ts +204 -0
- package/dist/react.js +490 -0
- package/dist/renderers/CodeBlock.d.ts +7 -0
- package/dist/renderers/CodeBlock.js +75 -0
- package/dist/renderers/Math.d.ts +14 -0
- package/dist/renderers/Math.js +15 -0
- package/dist/renderers/Mermaid.d.ts +13 -0
- package/dist/renderers/Mermaid.js +15 -0
- package/dist/server.d.ts +61 -0
- package/dist/server.js +126 -0
- package/{src/solid.tsx → dist/solid.d.ts} +19 -95
- package/dist/solid.js +54 -0
- package/dist/svelte.d.ts +80 -0
- package/dist/svelte.js +59 -0
- package/dist/types-core.d.ts +377 -0
- package/dist/types-core.js +0 -0
- package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
- package/dist/types-react.js +0 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/dist/vue.d.ts +94 -0
- package/dist/vue.js +79 -0
- package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
- package/{src → dist}/wasm/flux_md_core.js +50 -55
- package/dist/wasm/flux_md_core_bg.wasm +0 -0
- package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/dist/worker-core.d.ts +60 -0
- package/dist/worker-core.js +121 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +48 -0
- package/package.json +22 -18
- package/src/block-props.ts +0 -141
- package/src/dom.ts +0 -946
- package/src/element.ts +0 -400
- package/src/html-to-react.ts +0 -455
- package/src/morph.ts +0 -253
- package/src/react.tsx +0 -1020
- package/src/renderers/CodeBlock.tsx +0 -116
- package/src/renderers/Math.tsx +0 -28
- package/src/renderers/Mermaid.tsx +0 -27
- package/src/server.tsx +0 -221
- package/src/svelte.ts +0 -179
- package/src/types-core.ts +0 -398
- package/src/types.ts +0 -7
- package/src/vue.ts +0 -184
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/worker-core.ts +0 -174
- package/src/worker.ts +0 -72
- /package/{src → dist}/styles.css +0 -0
|
@@ -1,113 +1,36 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The ordered-block store backing a stream, extracted as a pure function so
|
|
5
|
-
* its reference-stability contract is testable without a Worker.
|
|
6
|
-
*
|
|
7
|
-
* **The contract that prevents extra React re-renders:** a block, once
|
|
8
|
-
* committed, is never re-sent by the parser, so `applyPatch` never replaces it
|
|
9
|
-
* in the map. Its object reference stays identical across every later patch —
|
|
10
|
-
* which is exactly what `blocksEqual` (the BlockView memo) checks, so committed
|
|
11
|
-
* blocks never re-render (and never re-parse) as the stream grows. Only the
|
|
12
|
-
* `active` tail gets fresh references each patch, and only it re-renders.
|
|
13
|
-
*/
|
|
14
|
-
export interface BlockStore {
|
|
15
|
-
committed: Map<number, Block>;
|
|
16
|
-
committedOrder: number[];
|
|
17
|
-
active: Block[];
|
|
18
|
-
snapshot: Block[];
|
|
1
|
+
function emptyBlockStore() {
|
|
2
|
+
return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
19
3
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return { committed: new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
4
|
+
function htmlToText(html) {
|
|
5
|
+
return html.replace(/<[^>]*>/g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&").replace(/\s+/g, " ").trim();
|
|
23
6
|
}
|
|
24
|
-
|
|
25
|
-
/** A heading entry for building a table of contents — see {@link FluxClient.outline}. */
|
|
26
|
-
export interface OutlineEntry {
|
|
27
|
-
/** Heading level 1–6. */
|
|
28
|
-
level: number;
|
|
29
|
-
/** Plain-text heading content (tags stripped, entities decoded). */
|
|
30
|
-
text: string;
|
|
31
|
-
/** Stable block id — usable as a scroll target / React key. */
|
|
32
|
-
id: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Strip tags (→ space) and decode the small entity set the core emits, then
|
|
36
|
-
* collapse whitespace. INVARIANT: the simple `<[^>]*>` strip is only safe
|
|
37
|
-
* because every input here is HTML the Rust core produced via escape_html /
|
|
38
|
-
* escape_attr — which escape `>` inside attribute values, so no `>` ever
|
|
39
|
-
* appears except as a real tag close. This must NOT be fed externally-authored
|
|
40
|
-
* HTML. `&` decodes last so `&lt;` → `<`, not `<`. */
|
|
41
|
-
function htmlToText(html: string): string {
|
|
42
|
-
return html
|
|
43
|
-
.replace(/<[^>]*>/g, " ")
|
|
44
|
-
.replace(/</g, "<")
|
|
45
|
-
.replace(/>/g, ">")
|
|
46
|
-
.replace(/"/g, '"')
|
|
47
|
-
.replace(/'/g, "'")
|
|
48
|
-
.replace(/&/g, "&")
|
|
49
|
-
.replace(/\s+/g, " ")
|
|
50
|
-
.trim();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function applyPatch(store: BlockStore, patch: Patch): void {
|
|
7
|
+
function applyPatch(store, patch) {
|
|
54
8
|
for (const b of patch.newly_committed) {
|
|
55
9
|
if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
|
|
56
10
|
store.committed.set(b.id, b);
|
|
57
11
|
}
|
|
58
12
|
store.active = patch.active;
|
|
59
|
-
|
|
60
|
-
// committed entries inside it are the same object references as before.
|
|
61
|
-
const next: Block[] = new Array(store.committedOrder.length + store.active.length);
|
|
13
|
+
const next = new Array(store.committedOrder.length + store.active.length);
|
|
62
14
|
for (let i = 0; i < store.committedOrder.length; i++) {
|
|
63
|
-
next[i] = store.committed.get(store.committedOrder[i])
|
|
15
|
+
next[i] = store.committed.get(store.committedOrder[i]);
|
|
64
16
|
}
|
|
65
17
|
for (let i = 0; i < store.active.length; i++) {
|
|
66
18
|
next[store.committedOrder.length + i] = store.active[i];
|
|
67
19
|
}
|
|
68
20
|
store.snapshot = next;
|
|
69
21
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
streamCount: number;
|
|
81
|
-
/** Live stream ids on this worker — so a fatal failure can notify each one. */
|
|
82
|
-
streamIds: Set<number>;
|
|
83
|
-
readyWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }>;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* A pool of Web Workers, each multiplexing many `FluxParser`s keyed by stream
|
|
88
|
-
* id. This is what lets flux-md scale past `hardwareConcurrency` concurrent
|
|
89
|
-
* streams without oversubscribing OS threads: 50 streams share (at most) the
|
|
90
|
-
* cap's worth of workers instead of spawning 50.
|
|
91
|
-
*
|
|
92
|
-
* Worker creation is **lazy and load-aware**: while under the cap, each new
|
|
93
|
-
* stream gets its own worker (so 1 stream = 1 worker, identical to the old
|
|
94
|
-
* behavior); once at the cap, new streams attach to the least-loaded worker.
|
|
95
|
-
*
|
|
96
|
-
* The constructor injects a `WorkerLike` factory so the routing and lifecycle
|
|
97
|
-
* logic is unit-testable with a fake worker — no real Worker or WASM needed.
|
|
98
|
-
*/
|
|
99
|
-
export class FluxPool {
|
|
100
|
-
private workers: PoolWorker[] = [];
|
|
101
|
-
private handlers = new Map<number, (msg: FromWorker) => void>();
|
|
102
|
-
private nextStreamId = 1;
|
|
103
|
-
|
|
104
|
-
constructor(
|
|
105
|
-
private factory: () => WorkerLike,
|
|
106
|
-
private cap: number,
|
|
107
|
-
) {}
|
|
108
|
-
|
|
22
|
+
class FluxPool {
|
|
23
|
+
constructor(factory, cap) {
|
|
24
|
+
this.factory = factory;
|
|
25
|
+
this.cap = cap;
|
|
26
|
+
}
|
|
27
|
+
factory;
|
|
28
|
+
cap;
|
|
29
|
+
workers = [];
|
|
30
|
+
handlers = /* @__PURE__ */ new Map();
|
|
31
|
+
nextStreamId = 1;
|
|
109
32
|
/** Reserve a stream id and assign a worker, registering its message handler. */
|
|
110
|
-
acquire(handler
|
|
33
|
+
acquire(handler) {
|
|
111
34
|
const streamId = this.nextStreamId++;
|
|
112
35
|
const pw = this.pick();
|
|
113
36
|
pw.streamCount++;
|
|
@@ -115,42 +38,36 @@ export class FluxPool {
|
|
|
115
38
|
this.handlers.set(streamId, handler);
|
|
116
39
|
return { streamId, pw };
|
|
117
40
|
}
|
|
118
|
-
|
|
119
41
|
/** Free a stream's parser in its worker; keep the worker warm for siblings. */
|
|
120
|
-
release(streamId
|
|
42
|
+
release(streamId, pw) {
|
|
121
43
|
this.handlers.delete(streamId);
|
|
122
44
|
pw.streamIds.delete(streamId);
|
|
123
45
|
pw.streamCount = Math.max(0, pw.streamCount - 1);
|
|
124
46
|
try {
|
|
125
47
|
pw.worker.postMessage({ type: "dispose", streamId });
|
|
126
48
|
} catch {
|
|
127
|
-
/* worker already gone */
|
|
128
49
|
}
|
|
129
50
|
}
|
|
130
|
-
|
|
131
51
|
/** Inverse of {@link release}: re-register a stream's handler so it receives
|
|
132
52
|
* patches again. For React StrictMode's dev double-mount, which destroys a
|
|
133
53
|
* client on the simulated unmount and remounts the SAME instance. The worker
|
|
134
54
|
* lazily recreates the disposed parser on the next append. */
|
|
135
|
-
reattach(streamId
|
|
55
|
+
reattach(streamId, pw, handler) {
|
|
136
56
|
if (!this.handlers.has(streamId)) {
|
|
137
57
|
pw.streamCount++;
|
|
138
58
|
pw.streamIds.add(streamId);
|
|
139
59
|
}
|
|
140
60
|
this.handlers.set(streamId, handler);
|
|
141
61
|
}
|
|
142
|
-
|
|
143
|
-
send(pw: PoolWorker, msg: ToWorker): void {
|
|
62
|
+
send(pw, msg) {
|
|
144
63
|
pw.worker.postMessage(msg);
|
|
145
64
|
}
|
|
146
|
-
|
|
147
65
|
/** Resolves when the given worker has finished WASM init; rejects if it failed. */
|
|
148
|
-
whenWorkerReady(pw
|
|
66
|
+
whenWorkerReady(pw) {
|
|
149
67
|
if (pw.ready) return Promise.resolve();
|
|
150
68
|
if (pw.failed) return Promise.reject(pw.failed);
|
|
151
69
|
return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
|
|
152
70
|
}
|
|
153
|
-
|
|
154
71
|
/**
|
|
155
72
|
* Eagerly spin up one worker so WASM init starts BEFORE the first stream —
|
|
156
73
|
* taking the one-time init off the first-token critical path (e.g. call
|
|
@@ -160,57 +77,51 @@ export class FluxPool {
|
|
|
160
77
|
* finished initializing WASM; rejects if init fails fatally. Browser-only (it
|
|
161
78
|
* constructs a `Worker`).
|
|
162
79
|
*/
|
|
163
|
-
warm()
|
|
80
|
+
warm() {
|
|
164
81
|
const live = this.workers.filter((w) => !w.failed);
|
|
165
82
|
const pw = live[0] ?? this.create();
|
|
166
83
|
return this.whenWorkerReady(pw);
|
|
167
84
|
}
|
|
168
|
-
|
|
169
85
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
170
|
-
disposeAll()
|
|
86
|
+
disposeAll() {
|
|
171
87
|
for (const pw of this.workers) {
|
|
172
88
|
try {
|
|
173
89
|
pw.worker.terminate();
|
|
174
90
|
} catch {
|
|
175
|
-
/* ignore */
|
|
176
91
|
}
|
|
177
92
|
}
|
|
178
93
|
this.workers = [];
|
|
179
94
|
this.handlers.clear();
|
|
180
95
|
}
|
|
181
|
-
|
|
182
|
-
get workerCount(): number {
|
|
96
|
+
get workerCount() {
|
|
183
97
|
return this.workers.length;
|
|
184
98
|
}
|
|
185
|
-
|
|
186
99
|
// Create a new worker while under cap and every live worker is busy; otherwise
|
|
187
100
|
// attach to the least-loaded LIVE worker. A fatally-failed worker is never
|
|
188
101
|
// handed out (a stream on it would post into a dead worker and hang) — it is
|
|
189
102
|
// retained only to reject outstanding whenWorkerReady waiters.
|
|
190
|
-
|
|
103
|
+
pick() {
|
|
191
104
|
const live = this.workers.filter((w) => !w.failed);
|
|
192
105
|
if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
|
|
193
106
|
return this.create();
|
|
194
107
|
}
|
|
195
108
|
if (live.length === 0) return this.create();
|
|
196
|
-
return live.reduce((a, b) =>
|
|
109
|
+
return live.reduce((a, b) => b.streamCount < a.streamCount ? b : a);
|
|
197
110
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const pw: PoolWorker = {
|
|
111
|
+
create() {
|
|
112
|
+
const pw = {
|
|
201
113
|
worker: this.factory(),
|
|
202
114
|
ready: false,
|
|
203
115
|
failed: null,
|
|
204
116
|
streamCount: 0,
|
|
205
|
-
streamIds: new Set(),
|
|
206
|
-
readyWaiters: []
|
|
117
|
+
streamIds: /* @__PURE__ */ new Set(),
|
|
118
|
+
readyWaiters: []
|
|
207
119
|
};
|
|
208
120
|
pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
|
|
209
121
|
this.workers.push(pw);
|
|
210
122
|
return pw;
|
|
211
123
|
}
|
|
212
|
-
|
|
213
|
-
private onMessage(pw: PoolWorker, msg: FromWorker): void {
|
|
124
|
+
onMessage(pw, msg) {
|
|
214
125
|
if (msg.type === "ready") {
|
|
215
126
|
pw.ready = true;
|
|
216
127
|
const waiters = pw.readyWaiters;
|
|
@@ -219,10 +130,6 @@ export class FluxPool {
|
|
|
219
130
|
return;
|
|
220
131
|
}
|
|
221
132
|
if (msg.type === "error" && msg.fatal) {
|
|
222
|
-
// A fatal (WASM-init) failure dooms every stream on this worker. Reject
|
|
223
|
-
// anyone awaiting readiness, then notify each live stream's client so its
|
|
224
|
-
// onError fires — the message carries no real streamId to route by. The
|
|
225
|
-
// worker is kept only to reject those waiters; pick() never reuses it.
|
|
226
133
|
const err = new Error(msg.message);
|
|
227
134
|
pw.failed = err;
|
|
228
135
|
const waiters = pw.readyWaiters;
|
|
@@ -231,7 +138,6 @@ export class FluxPool {
|
|
|
231
138
|
try {
|
|
232
139
|
w.reject(err);
|
|
233
140
|
} catch {
|
|
234
|
-
/* a waiter's rejection handler is the caller's problem, not ours */
|
|
235
141
|
}
|
|
236
142
|
}
|
|
237
143
|
for (const sid of pw.streamIds) this.dispatch(sid, msg);
|
|
@@ -239,83 +145,50 @@ export class FluxPool {
|
|
|
239
145
|
}
|
|
240
146
|
this.dispatch(msg.streamId, msg);
|
|
241
147
|
}
|
|
242
|
-
|
|
243
148
|
// Route a message to a stream's handler, isolating a throwing client callback
|
|
244
149
|
// (e.g. a user-supplied onError) so it can neither break the worker message
|
|
245
150
|
// loop nor starve sibling streams sharing this worker.
|
|
246
|
-
|
|
151
|
+
dispatch(streamId, msg) {
|
|
247
152
|
try {
|
|
248
153
|
this.handlers.get(streamId)?.(msg);
|
|
249
154
|
} catch (e) {
|
|
250
|
-
// eslint-disable-next-line no-console
|
|
251
155
|
console.error("flux: stream message handler threw", e);
|
|
252
156
|
}
|
|
253
157
|
}
|
|
254
158
|
}
|
|
255
|
-
|
|
256
|
-
function poolCap(): number {
|
|
159
|
+
function poolCap() {
|
|
257
160
|
const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
|
|
258
161
|
return Math.min(hc || 4, 8);
|
|
259
162
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
/** The process-wide default pool every `FluxClient` shares unless given one. */
|
|
264
|
-
export function getDefaultPool(): FluxPool {
|
|
163
|
+
let defaultPool = null;
|
|
164
|
+
function getDefaultPool() {
|
|
265
165
|
if (!defaultPool) {
|
|
266
166
|
defaultPool = new FluxPool(
|
|
267
|
-
() => new Worker(new URL("./worker.
|
|
268
|
-
poolCap()
|
|
167
|
+
() => new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
|
|
168
|
+
poolCap()
|
|
269
169
|
);
|
|
270
170
|
}
|
|
271
171
|
return defaultPool;
|
|
272
172
|
}
|
|
273
|
-
|
|
274
|
-
/** TEST-ONLY: drop the process-wide default pool so the next {@link getDefaultPool}
|
|
275
|
-
* rebuilds it (lazily, with the current global `Worker`). Lets a test file that
|
|
276
|
-
* drives the default pool start from a clean, deterministic state regardless of
|
|
277
|
-
* which other file warmed it first in bun's shared test process. Not part of the
|
|
278
|
-
* public API and a no-op for normal runtime use. */
|
|
279
|
-
export function __resetDefaultPool(): void {
|
|
173
|
+
function __resetDefaultPool() {
|
|
280
174
|
defaultPool = null;
|
|
281
175
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
* - subscribe(listener): for React's useSyncExternalStore
|
|
294
|
-
* - getSnapshot(): the current ordered list of blocks
|
|
295
|
-
* - getMetrics(): per-stream perf metrics
|
|
296
|
-
*
|
|
297
|
-
* Mutation methods:
|
|
298
|
-
* - append(chunk): forward to the worker
|
|
299
|
-
* - finalize(): mark the stream done
|
|
300
|
-
* - reset(): start fresh
|
|
301
|
-
*/
|
|
302
|
-
export class FluxClient {
|
|
303
|
-
private pool: FluxPool;
|
|
304
|
-
private pw: PoolWorker | null = null;
|
|
305
|
-
private streamId = 0;
|
|
306
|
-
private config?: ParserConfig;
|
|
307
|
-
private configSent = false;
|
|
308
|
-
private listeners = new Set<() => void>();
|
|
309
|
-
private store: BlockStore = emptyBlockStore();
|
|
310
|
-
private onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
311
|
-
private onBlock?: (block: Block) => void;
|
|
312
|
-
private attached = true;
|
|
176
|
+
class FluxClient {
|
|
177
|
+
pool;
|
|
178
|
+
pw = null;
|
|
179
|
+
streamId = 0;
|
|
180
|
+
config;
|
|
181
|
+
configSent = false;
|
|
182
|
+
listeners = /* @__PURE__ */ new Set();
|
|
183
|
+
store = emptyBlockStore();
|
|
184
|
+
onError;
|
|
185
|
+
onBlock;
|
|
186
|
+
attached = true;
|
|
313
187
|
// Diff baseline for setContent(): the full string fed in so far, and whether
|
|
314
188
|
// it has been finalized. Cleared by reset()/reattach() (the worker drops the
|
|
315
189
|
// parser there, so the baseline is stale and the document must be re-fed).
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
190
|
+
lastContent = "";
|
|
191
|
+
contentDone = false;
|
|
319
192
|
// Opt-in rAF coalescing (see constructor `coalesce`). When on AND
|
|
320
193
|
// requestAnimationFrame exists, intra-frame emit()s collapse into ONE
|
|
321
194
|
// rAF-scheduled flush to listeners — the React useSyncExternalStore path then
|
|
@@ -324,26 +197,24 @@ export class FluxClient {
|
|
|
324
197
|
// reference-stable, so a dropped intermediate notify only skips a tail-only
|
|
325
198
|
// render that the next flush supersedes. The finalize/done patch is exempt and
|
|
326
199
|
// flushes synchronously, never deferred to the next frame.
|
|
327
|
-
|
|
328
|
-
|
|
200
|
+
coalesce = false;
|
|
201
|
+
rafHandle = null;
|
|
329
202
|
// Set by finalize(); the next patch's emit flushes synchronously (a 'done'
|
|
330
203
|
// notification must not be deferred a frame) and clears it.
|
|
331
|
-
|
|
332
|
-
|
|
204
|
+
finalizePending = false;
|
|
333
205
|
// Perf
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
206
|
+
appendedBytes = 0;
|
|
207
|
+
patchCount = 0;
|
|
208
|
+
totalParseMicros = 0;
|
|
209
|
+
lastPatchMs = 0;
|
|
210
|
+
firstAppendMs = 0;
|
|
211
|
+
retainedBytes = 0;
|
|
212
|
+
wasmMemoryBytes = 0;
|
|
341
213
|
// Render-path observability (advanced ONLY when an onRenderMetrics hook is
|
|
342
214
|
// wired into a renderer; zero-cost otherwise). renderCount = React BlockView
|
|
343
215
|
// body renders; rebuildCount = DOM node rebuilds.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
216
|
+
renderCount = 0;
|
|
217
|
+
rebuildCount = 0;
|
|
347
218
|
/**
|
|
348
219
|
* @param options.pool worker pool to join (defaults to the shared
|
|
349
220
|
* process-wide pool — pass a dedicated `FluxPool` only for isolation).
|
|
@@ -365,22 +236,13 @@ export class FluxClient {
|
|
|
365
236
|
* pending frame is cancelled on `reset()`/`destroy()`. No effect when
|
|
366
237
|
* `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
|
|
367
238
|
*/
|
|
368
|
-
constructor(
|
|
369
|
-
options: {
|
|
370
|
-
pool?: FluxPool;
|
|
371
|
-
config?: ParserConfig;
|
|
372
|
-
onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
373
|
-
onBlock?: (block: Block) => void;
|
|
374
|
-
coalesce?: boolean;
|
|
375
|
-
} = {},
|
|
376
|
-
) {
|
|
239
|
+
constructor(options = {}) {
|
|
377
240
|
this.pool = options.pool ?? getDefaultPool();
|
|
378
241
|
this.config = options.config;
|
|
379
242
|
this.onError = options.onError;
|
|
380
243
|
this.onBlock = options.onBlock;
|
|
381
244
|
this.coalesce = options.coalesce ?? false;
|
|
382
245
|
}
|
|
383
|
-
|
|
384
246
|
/**
|
|
385
247
|
* Lazily reserve this client's stream id and bind it to a pool worker. The
|
|
386
248
|
* SOLE place that calls pool.acquire() — so the worker is created on the FIRST
|
|
@@ -396,46 +258,38 @@ export class FluxClient {
|
|
|
396
258
|
* necessarily owns the lowest streamId. This affects neither the pool cap nor
|
|
397
259
|
* multiplexing (pick() is unchanged and remains the only path to create()).
|
|
398
260
|
*/
|
|
399
|
-
|
|
261
|
+
ensureAcquired() {
|
|
400
262
|
if (this.pw) return this.pw;
|
|
401
263
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
402
264
|
this.streamId = streamId;
|
|
403
265
|
this.pw = pw;
|
|
404
266
|
return pw;
|
|
405
267
|
}
|
|
406
|
-
|
|
407
|
-
get ready(): boolean {
|
|
268
|
+
get ready() {
|
|
408
269
|
return this.pw?.ready ?? false;
|
|
409
270
|
}
|
|
410
|
-
|
|
411
|
-
whenReady(): Promise<void> {
|
|
271
|
+
whenReady() {
|
|
412
272
|
const pw = this.ensureAcquired();
|
|
413
273
|
return this.pool.whenWorkerReady(pw);
|
|
414
274
|
}
|
|
415
|
-
|
|
416
275
|
// The config rides on the first message a stream sends; the worker applies it
|
|
417
276
|
// when it creates the parser. postMessage is FIFO per worker, so it always
|
|
418
277
|
// lands before any append is processed. Returns undefined after the first use.
|
|
419
|
-
|
|
420
|
-
if (this.configSent || !this.config) return
|
|
278
|
+
firstConfig() {
|
|
279
|
+
if (this.configSent || !this.config) return void 0;
|
|
421
280
|
this.configSent = true;
|
|
422
281
|
return this.config;
|
|
423
282
|
}
|
|
424
|
-
|
|
425
|
-
append(chunk: string) {
|
|
283
|
+
append(chunk) {
|
|
426
284
|
const pw = this.ensureAcquired();
|
|
427
285
|
if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
|
|
428
286
|
this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
|
|
429
287
|
}
|
|
430
|
-
|
|
431
288
|
finalize() {
|
|
432
289
|
const pw = this.ensureAcquired();
|
|
433
|
-
// The terminal patch this triggers must reach subscribers synchronously, not
|
|
434
|
-
// a frame late — mark it so the next emit flushes now even under coalescing.
|
|
435
290
|
this.finalizePending = true;
|
|
436
291
|
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
|
|
437
292
|
}
|
|
438
|
-
|
|
439
293
|
/**
|
|
440
294
|
* Pipe a source straight in: read it to completion, `append()` each chunk,
|
|
441
295
|
* then `finalize()`. The LLM-native path — e.g.
|
|
@@ -453,60 +307,46 @@ export class FluxClient {
|
|
|
453
307
|
* finalized (or cleanly on abort); rejects if the source itself errors.
|
|
454
308
|
* Browser-only for byte sources (uses `TextDecoder`).
|
|
455
309
|
*/
|
|
456
|
-
async pipeFrom(
|
|
457
|
-
source: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
|
|
458
|
-
opts?: { signal?: AbortSignal },
|
|
459
|
-
): Promise<void> {
|
|
310
|
+
async pipeFrom(source, opts) {
|
|
460
311
|
const signal = opts?.signal;
|
|
461
|
-
|
|
462
|
-
if (signal?.aborted) return; // already superseded before we started
|
|
463
|
-
|
|
464
|
-
// AsyncIterable<string> (SSE deltas, generators). Detected by elimination:
|
|
465
|
-
// a ReadableStream has `getReader`, a Response has `body` — neither here.
|
|
312
|
+
if (signal?.aborted) return;
|
|
466
313
|
if (!("getReader" in source) && !("body" in source)) {
|
|
467
|
-
for await (const chunk of source
|
|
468
|
-
if (signal?.aborted) return;
|
|
314
|
+
for await (const chunk of source) {
|
|
315
|
+
if (signal?.aborted) return;
|
|
469
316
|
this.append(chunk);
|
|
470
317
|
}
|
|
471
318
|
if (!signal?.aborted) this.finalize();
|
|
472
319
|
return;
|
|
473
320
|
}
|
|
474
|
-
|
|
475
|
-
// Byte source: a Response (use its body) or a ReadableStream directly.
|
|
476
321
|
const body = "body" in source ? source.body : source;
|
|
477
322
|
if (!body) {
|
|
478
|
-
// An empty Response body (e.g. 204) is a completed, empty stream.
|
|
479
323
|
this.finalize();
|
|
480
324
|
return;
|
|
481
325
|
}
|
|
482
326
|
const reader = body.getReader();
|
|
483
|
-
// A pending read() can't observe `aborted` until the next chunk; cancel()
|
|
484
|
-
// on abort tears down the upstream and resolves the pending read so the
|
|
485
|
-
// loop's post-read check fires and bails without finalizing.
|
|
486
327
|
const onAbort = () => {
|
|
487
|
-
reader.cancel().catch(() => {
|
|
328
|
+
reader.cancel().catch(() => {
|
|
329
|
+
});
|
|
488
330
|
};
|
|
489
331
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
490
332
|
const decoder = new TextDecoder();
|
|
491
333
|
try {
|
|
492
|
-
for (
|
|
334
|
+
for (; ; ) {
|
|
493
335
|
const { done, value } = await reader.read();
|
|
494
|
-
if (signal?.aborted) return;
|
|
336
|
+
if (signal?.aborted) return;
|
|
495
337
|
if (done) break;
|
|
496
338
|
if (value) this.append(decoder.decode(value, { stream: true }));
|
|
497
339
|
}
|
|
498
|
-
this.append(decoder.decode());
|
|
340
|
+
this.append(decoder.decode());
|
|
499
341
|
this.finalize();
|
|
500
342
|
} finally {
|
|
501
343
|
signal?.removeEventListener("abort", onAbort);
|
|
502
344
|
try {
|
|
503
345
|
reader.releaseLock();
|
|
504
346
|
} catch {
|
|
505
|
-
/* already released (e.g. by cancel) */
|
|
506
347
|
}
|
|
507
348
|
}
|
|
508
349
|
}
|
|
509
|
-
|
|
510
350
|
/**
|
|
511
351
|
* Drive the parser from a CONTROLLED full string instead of manual appends.
|
|
512
352
|
* Pass the whole document-so-far each time; setContent diffs it against the
|
|
@@ -532,17 +372,12 @@ export class FluxClient {
|
|
|
532
372
|
* here (it forces a reparse each tick); do that enrichment at render time via
|
|
533
373
|
* `components` instead, keeping the source append-only.
|
|
534
374
|
*/
|
|
535
|
-
setContent(content
|
|
375
|
+
setContent(content, opts) {
|
|
536
376
|
if (content !== this.lastContent) {
|
|
537
|
-
// Fast path appends the delta into the EXISTING parser — but a parser that
|
|
538
|
-
// was already finalized ({ done: true }) is terminal: the core drops any
|
|
539
|
-
// further append. So gate the fast path on !contentDone; reopening a
|
|
540
|
-
// finalized stream (or any divergence) falls through to reset()+reparse,
|
|
541
|
-
// which frees the dead parser and rebuilds a fresh one.
|
|
542
377
|
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
543
378
|
this.append(content.slice(this.lastContent.length));
|
|
544
379
|
} else {
|
|
545
|
-
this.reset();
|
|
380
|
+
this.reset();
|
|
546
381
|
this.append(content);
|
|
547
382
|
}
|
|
548
383
|
this.lastContent = content;
|
|
@@ -553,12 +388,7 @@ export class FluxClient {
|
|
|
553
388
|
this.contentDone = true;
|
|
554
389
|
}
|
|
555
390
|
}
|
|
556
|
-
|
|
557
391
|
reset() {
|
|
558
|
-
// Only notify subscribers if there was content to clear: resetting an
|
|
559
|
-
// already-empty store leaves the view empty either way, so skip the no-op
|
|
560
|
-
// emit (which would otherwise drive every subscriber through a wasted,
|
|
561
|
-
// output-identical render pass).
|
|
562
392
|
const hadContent = this.store.snapshot.length > 0;
|
|
563
393
|
this.store = emptyBlockStore();
|
|
564
394
|
this.appendedBytes = 0;
|
|
@@ -568,33 +398,22 @@ export class FluxClient {
|
|
|
568
398
|
this.firstAppendMs = 0;
|
|
569
399
|
this.retainedBytes = 0;
|
|
570
400
|
this.wasmMemoryBytes = 0;
|
|
571
|
-
this.lastContent = "";
|
|
401
|
+
this.lastContent = "";
|
|
572
402
|
this.contentDone = false;
|
|
573
|
-
// A frame coalesced from the just-cleared stream is now stale — cancel it so
|
|
574
|
-
// it can't fire a notify referencing content reset() just dropped.
|
|
575
403
|
this.cancelFrame();
|
|
576
404
|
this.finalizePending = false;
|
|
577
|
-
// Same streamId + worker — the worker frees and lazily recreates the parser.
|
|
578
405
|
const pw = this.ensureAcquired();
|
|
579
406
|
this.pool.send(pw, { type: "reset", streamId: this.streamId });
|
|
580
|
-
if (hadContent) this.emit(true);
|
|
407
|
+
if (hadContent) this.emit(true);
|
|
581
408
|
}
|
|
582
|
-
|
|
583
409
|
destroy() {
|
|
584
|
-
if (!this.attached) return;
|
|
585
|
-
// Free this stream's parser; the shared worker stays warm for siblings.
|
|
586
|
-
// Only release a real slot — a never-acquired client (constructed during an
|
|
587
|
-
// SSR render then unmounted) has no pool slot to free, so skip the call.
|
|
588
|
-
// We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
|
|
589
|
-
// on the SAME instance needs the same pw/streamId to re-register.
|
|
410
|
+
if (!this.attached) return;
|
|
590
411
|
if (this.pw) this.pool.release(this.streamId, this.pw);
|
|
591
|
-
// Drop any pending coalesced frame so it can't fire into cleared listeners.
|
|
592
412
|
this.cancelFrame();
|
|
593
413
|
this.finalizePending = false;
|
|
594
414
|
this.listeners.clear();
|
|
595
415
|
this.attached = false;
|
|
596
416
|
}
|
|
597
|
-
|
|
598
417
|
/**
|
|
599
418
|
* Re-register with the pool after {@link destroy} so the client receives
|
|
600
419
|
* patches again. Needed only for React StrictMode's dev double-mount, where
|
|
@@ -603,61 +422,46 @@ export class FluxClient {
|
|
|
603
422
|
*/
|
|
604
423
|
reattach() {
|
|
605
424
|
if (this.attached) return;
|
|
606
|
-
// The prior destroy()→dispose dropped this stream's parser, so setContent's
|
|
607
|
-
// diff baseline is stale — clear it so the next setContent re-feeds the whole
|
|
608
|
-
// document (StrictMode dev double-mount on the SAME instance).
|
|
609
425
|
this.lastContent = "";
|
|
610
426
|
this.contentDone = false;
|
|
611
427
|
if (!this.pw) {
|
|
612
|
-
// Never acquired (e.g. constructed during SSR, first real mount on client).
|
|
613
|
-
// No prior pool slot to re-register; just mark attached. The next
|
|
614
|
-
// worker-bound op acquires lazily. configSent is already false, so the
|
|
615
|
-
// first append will carry config exactly as a brand-new client would.
|
|
616
428
|
this.attached = true;
|
|
617
429
|
return;
|
|
618
430
|
}
|
|
619
431
|
this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
|
|
620
432
|
this.attached = true;
|
|
621
|
-
// The worker discarded this stream's config on `dispose` (unlike `reset`,
|
|
622
|
-
// which keeps it), so re-send it on the next message — otherwise the parser
|
|
623
|
-
// would be rebuilt with library defaults (gfmMath / componentTags / … lost).
|
|
624
433
|
this.configSent = false;
|
|
625
434
|
}
|
|
626
|
-
|
|
627
|
-
subscribe = (fn: () => void) => {
|
|
435
|
+
subscribe = (fn) => {
|
|
628
436
|
this.listeners.add(fn);
|
|
629
437
|
return () => this.listeners.delete(fn);
|
|
630
438
|
};
|
|
631
|
-
|
|
632
|
-
getSnapshot = (): Block[] => this.store.snapshot;
|
|
633
|
-
|
|
439
|
+
getSnapshot = () => this.store.snapshot;
|
|
634
440
|
/**
|
|
635
441
|
* Internal: a renderer with an `onRenderMetrics` hook calls this once per
|
|
636
442
|
* actual React block render so `getMetrics().renderCount` aggregates churn.
|
|
637
443
|
* No-op cost when no hook is wired (it is simply never called). Not part of
|
|
638
444
|
* the public API surface — the underscore marks it renderer-internal.
|
|
639
445
|
*/
|
|
640
|
-
__noteRender()
|
|
446
|
+
__noteRender() {
|
|
641
447
|
this.renderCount++;
|
|
642
448
|
}
|
|
643
|
-
|
|
644
449
|
/**
|
|
645
450
|
* Internal: the DOM renderer calls this once per actual node rebuild (the
|
|
646
451
|
* changed-block branch) when an `onRenderMetrics` hook is wired, so
|
|
647
452
|
* `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
|
|
648
453
|
*/
|
|
649
|
-
__noteRebuild()
|
|
454
|
+
__noteRebuild() {
|
|
650
455
|
this.rebuildCount++;
|
|
651
456
|
}
|
|
652
|
-
|
|
653
457
|
getMetrics() {
|
|
654
458
|
const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
|
|
655
459
|
return {
|
|
656
460
|
bytes: this.appendedBytes,
|
|
657
461
|
patches: this.patchCount,
|
|
658
462
|
meanParseMicros: this.patchCount > 0 ? this.totalParseMicros / this.patchCount : 0,
|
|
659
|
-
totalParseMs: this.totalParseMicros /
|
|
660
|
-
throughputKBs:
|
|
463
|
+
totalParseMs: this.totalParseMicros / 1e3,
|
|
464
|
+
throughputKBs: this.appendedBytes / 1024 / (elapsed / 1e3),
|
|
661
465
|
committedBlocks: this.store.committed.size,
|
|
662
466
|
activeBlocks: this.store.active.length,
|
|
663
467
|
lastPatchAgoMs: this.lastPatchMs === 0 ? 0 : performance.now() - this.lastPatchMs,
|
|
@@ -670,31 +474,25 @@ export class FluxClient {
|
|
|
670
474
|
// renderer): renderCount = React block-body renders, rebuildCount = DOM
|
|
671
475
|
// node rebuilds. Committed blocks memo-skip, so they contribute once.
|
|
672
476
|
renderCount: this.renderCount,
|
|
673
|
-
rebuildCount: this.rebuildCount
|
|
477
|
+
rebuildCount: this.rebuildCount
|
|
674
478
|
};
|
|
675
479
|
}
|
|
676
|
-
|
|
677
480
|
/**
|
|
678
481
|
* A heading outline of the current snapshot (committed + active), in document
|
|
679
482
|
* order — for a table of contents. Works mid-stream; entries appear as their
|
|
680
483
|
* headings stream in. The `id` is stable, so a built ToC won't re-key.
|
|
681
484
|
*/
|
|
682
|
-
outline()
|
|
683
|
-
const out
|
|
485
|
+
outline() {
|
|
486
|
+
const out = [];
|
|
684
487
|
for (const b of this.store.snapshot) {
|
|
685
488
|
if (b.kind.type === "Heading") {
|
|
686
|
-
|
|
687
|
-
// `{ level, text, id }` object when on — accept both. `OutlineEntry.id`
|
|
688
|
-
// stays the numeric block id (stable, non-breaking); the anchor slug is
|
|
689
|
-
// reachable additively via `kind.data.id` for consumers who want it.
|
|
690
|
-
const d = b.kind.data as number | { level?: number } | undefined;
|
|
489
|
+
const d = b.kind.data;
|
|
691
490
|
const level = typeof d === "number" ? d : d?.level ?? 1;
|
|
692
491
|
out.push({ level, text: htmlToText(b.html), id: b.id });
|
|
693
492
|
}
|
|
694
493
|
}
|
|
695
494
|
return out;
|
|
696
495
|
}
|
|
697
|
-
|
|
698
496
|
/**
|
|
699
497
|
* The rendered document as plain text — tags stripped, entities decoded,
|
|
700
498
|
* blocks separated by blank lines. Derived from the rendered HTML (the source
|
|
@@ -702,48 +500,50 @@ export class FluxClient {
|
|
|
702
500
|
* readable approximation for search indexing / summaries, not a round-trip of
|
|
703
501
|
* the original source.
|
|
704
502
|
*/
|
|
705
|
-
toPlaintext()
|
|
706
|
-
const parts
|
|
503
|
+
toPlaintext() {
|
|
504
|
+
const parts = [];
|
|
707
505
|
for (const b of this.store.snapshot) {
|
|
708
506
|
const t = htmlToText(b.html);
|
|
709
507
|
if (t) parts.push(t);
|
|
710
508
|
}
|
|
711
509
|
return parts.join("\n\n");
|
|
712
510
|
}
|
|
713
|
-
|
|
714
|
-
private onMessage(msg: FromWorker) {
|
|
511
|
+
onMessage(msg) {
|
|
715
512
|
switch (msg.type) {
|
|
716
|
-
case "patch":
|
|
717
|
-
|
|
513
|
+
case "patch": {
|
|
514
|
+
let patch;
|
|
515
|
+
try {
|
|
516
|
+
patch = JSON.parse(msg.patch);
|
|
517
|
+
} catch (e) {
|
|
518
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
519
|
+
if (this.onError) this.onError({ message: `flux: malformed patch (${message})` });
|
|
520
|
+
else console.error("flux: malformed patch:", message);
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
applyPatch(this.store, patch);
|
|
718
524
|
this.appendedBytes = msg.appendedBytes;
|
|
719
525
|
this.totalParseMicros += msg.parseMicros;
|
|
720
526
|
this.retainedBytes = msg.retainedBytes;
|
|
721
527
|
this.wasmMemoryBytes = msg.wasmMemoryBytes;
|
|
722
528
|
this.patchCount += 1;
|
|
723
529
|
this.lastPatchMs = performance.now();
|
|
724
|
-
// The post-finalize (done) patch must notify synchronously — never defer
|
|
725
|
-
// stream completion to the next frame. One-shot: cleared after use.
|
|
726
530
|
const sync = this.finalizePending;
|
|
727
531
|
this.finalizePending = false;
|
|
728
532
|
this.emit(sync);
|
|
729
|
-
// After subscribers see the new snapshot, fire the per-block hook for
|
|
730
|
-
// anything that just committed (document order). A throw here is
|
|
731
|
-
// isolated by the pool's dispatch boundary and won't skip emit().
|
|
732
533
|
if (this.onBlock) {
|
|
733
|
-
for (const b of
|
|
534
|
+
for (const b of patch.newly_committed) this.onBlock(b);
|
|
734
535
|
}
|
|
735
536
|
break;
|
|
537
|
+
}
|
|
736
538
|
case "error":
|
|
737
539
|
if (this.onError) {
|
|
738
540
|
this.onError({ message: msg.message, fatal: msg.fatal });
|
|
739
541
|
} else {
|
|
740
|
-
// eslint-disable-next-line no-console
|
|
741
542
|
console.error("flux worker error:", msg.message);
|
|
742
543
|
}
|
|
743
544
|
break;
|
|
744
545
|
}
|
|
745
546
|
}
|
|
746
|
-
|
|
747
547
|
/**
|
|
748
548
|
* Notify subscribers of a new snapshot.
|
|
749
549
|
*
|
|
@@ -753,29 +553,33 @@ export class FluxClient {
|
|
|
753
553
|
* into one notify. `sync` forces an immediate flush (stream completion / reset)
|
|
754
554
|
* and cancels any frame already pending so the snapshot is delivered once.
|
|
755
555
|
*/
|
|
756
|
-
|
|
556
|
+
emit(sync = false) {
|
|
757
557
|
if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
|
|
758
|
-
if (this.rafHandle !== null) return;
|
|
558
|
+
if (this.rafHandle !== null) return;
|
|
759
559
|
this.rafHandle = requestAnimationFrame(() => {
|
|
760
560
|
this.rafHandle = null;
|
|
761
561
|
this.flushNow();
|
|
762
562
|
});
|
|
763
563
|
return;
|
|
764
564
|
}
|
|
765
|
-
// Synchronous path: drop any pending frame so its notify can't double-fire
|
|
766
|
-
// after this one, then flush immediately.
|
|
767
565
|
this.cancelFrame();
|
|
768
566
|
this.flushNow();
|
|
769
567
|
}
|
|
770
|
-
|
|
771
|
-
private flushNow() {
|
|
568
|
+
flushNow() {
|
|
772
569
|
for (const fn of this.listeners) fn();
|
|
773
570
|
}
|
|
774
|
-
|
|
775
|
-
private cancelFrame() {
|
|
571
|
+
cancelFrame() {
|
|
776
572
|
if (this.rafHandle !== null) {
|
|
777
573
|
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
|
|
778
574
|
this.rafHandle = null;
|
|
779
575
|
}
|
|
780
576
|
}
|
|
781
577
|
}
|
|
578
|
+
export {
|
|
579
|
+
FluxClient,
|
|
580
|
+
FluxPool,
|
|
581
|
+
__resetDefaultPool,
|
|
582
|
+
applyPatch,
|
|
583
|
+
emptyBlockStore,
|
|
584
|
+
getDefaultPool
|
|
585
|
+
};
|