flux-md 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -241,7 +241,11 @@ controlled-string helpers wrap; in vanilla you call it directly.
241
241
 
242
242
  `mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
243
243
  Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
244
- (default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
244
+ (default true), `batch` (default true — one DOM write per `requestAnimationFrame`),
245
+ `morphOpenBlocks` (default false — morph a growing generic open block's subtree in
246
+ place instead of rebuilding it via `innerHTML`, so only the changed parts repaint
247
+ and focus/selection in the streaming tail survive; the rendered result is
248
+ equivalent to the default rebuild path).
245
249
  Block-kind overrides use `components` keyed by block-kind (`CodeBlock`, `Table`,
246
250
  `Alert`, `Component`, …) with values `(props) => HTMLElement | string`. Tag-level
247
251
  (lowercase `a`/`table`/`code`) overrides are **React-only** — there's no virtual
@@ -995,6 +999,43 @@ genuinely hostile content where CSS-overlay/clickjacking matters, render inside
995
999
  a sandboxed `<iframe>` instead — sanitization stops injection, not every
996
1000
  visual-overlay trick.
997
1001
 
1002
+ ### Supply-chain transparency
1003
+
1004
+ flux-md is **zero runtime dependency** — no third-party packages are pulled in
1005
+ at runtime. The parsing core is Rust compiled to WebAssembly, reproducibly
1006
+ buildable from `crates/flux-md-core/` via `bun run build:wasm`.
1007
+
1008
+ **Native code (WebAssembly).** The shipped `flux_md_core_bg.wasm` (~200 KB) is
1009
+ first-party, built from the Rust source in this repo, and runs inside a sandboxed
1010
+ Web Worker (browser) or Node worker thread. Supply-chain scanners such as
1011
+ [Socket.dev](https://socket.dev) will flag it as `nativeCode` — this is accurate
1012
+ and expected. The WASM is not a vendored third-party binary; it is reproducible
1013
+ from source.
1014
+
1015
+ **Network access.** flux-md performs network I/O in exactly two scenarios, both
1016
+ caller-driven:
1017
+
1018
+ - `<flux-markdown src="URL">` — the Web Component fetches the URL you supply and
1019
+ streams the response. No URL is ever chosen by flux-md itself.
1020
+ - The wasm-bindgen glue (`wasm/flux_md_core.js`) loads the co-located `.wasm`
1021
+ asset via `fetch(new URL("…_bg.wasm", import.meta.url))` — bundlers resolve
1022
+ this to a local build artifact, not a remote endpoint.
1023
+
1024
+ flux-md has no telemetry, no analytics, and no first-party remote endpoints.
1025
+ Socket will flag the `networkAccess` signal — it is accurate and expected. In
1026
+ privileged contexts (browser extensions, Electron, environments where the
1027
+ same-origin policy may not apply), treat the `src` attribute value as you would
1028
+ any external URL and allowlist it in your CSP / security policy.
1029
+
1030
+ **Filesystem access (Node/SSR only).** `flux-md/server` reads the package's
1031
+ own `.wasm` file off disk on Node.js (Node's `fetch` cannot load `file://`
1032
+ URLs). This is a Node-only path; it reads only the package-internal asset and
1033
+ never touches caller-supplied paths. Socket will flag `filesystemAccess` — also
1034
+ accurate and expected.
1035
+
1036
+ The `socket.yml` at the repository root documents these signals with their
1037
+ justifications for Socket's GitHub app.
1038
+
998
1039
  ## Scaling
999
1040
 
1000
1041
  `FluxClient`s share a **worker pool** (`getDefaultPool()`), so concurrency
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.15.0",
3
+ "version": "0.16.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"],
@@ -22,8 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "src",
25
- "README.md",
26
- "CHANGELOG.md"
25
+ "README.md"
27
26
  ],
28
27
  "peerDependencies": {
29
28
  "react": ">=18",
@@ -2,6 +2,7 @@ import type {
2
2
  Block,
3
3
  BlockComponentProps,
4
4
  CodeBlockData,
5
+ ContainerData,
5
6
  HeadingData,
6
7
  ListData,
7
8
  MathBlockData,
@@ -86,7 +87,7 @@ export function blockProps(block: Block): BlockComponentProps {
86
87
  speculative: block.speculative,
87
88
  };
88
89
  const data = block.kind.data as
89
- | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
90
+ | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; items?: { html: string }[]; tag?: string; attrs?: [string, string][] }
90
91
  | undefined;
91
92
  if (block.kind.type === "CodeBlock") {
92
93
  // Prefer the structured `code` (present when blockData is on) over the HTML
@@ -105,9 +106,10 @@ export function blockProps(block: Block): BlockComponentProps {
105
106
  }
106
107
  } else if (block.kind.type === "List") {
107
108
  // Structured list data is present only when blockData is on (the `start` key
108
- // rides the opt-in channel); surface it as the typed convenience field.
109
+ // rides the opt-in channel); surface it as the typed convenience field,
110
+ // including the opt-in per-item HTML (`items`) for a keyed renderer.
109
111
  if (data && typeof data.start === "number") {
110
- props.list = { ordered: !!data.ordered, start: data.start } as ListData;
112
+ props.list = { ordered: !!data.ordered, start: data.start, items: data.items } as ListData;
111
113
  }
112
114
  } else if (block.kind.type === "Component") {
113
115
  props.tag = data?.tag ?? "";
@@ -126,6 +128,14 @@ export function blockProps(block: Block): BlockComponentProps {
126
128
  if (typeof block.kind.data === "object" && block.kind.data !== null) {
127
129
  props.heading = block.kind.data as HeadingData;
128
130
  }
131
+ } else if (block.kind.type === "Blockquote" || block.kind.type === "Alert") {
132
+ // When `blockData` is on, a Blockquote's `kind.data` is `{ nested }` and an
133
+ // Alert's is `{ kind, nested }`; off, a Blockquote has no `data` and an Alert
134
+ // only `{ kind }`. Surface the keyed `nested` sub-blocks only when present.
135
+ const cd = block.kind.data as { nested?: { html: string }[] } | undefined;
136
+ if (cd && Array.isArray(cd.nested)) {
137
+ props.container = { nested: cd.nested } as ContainerData;
138
+ }
129
139
  }
130
140
  return props;
131
141
  }
package/src/client.ts CHANGED
@@ -271,6 +271,15 @@ export function getDefaultPool(): FluxPool {
271
271
  return defaultPool;
272
272
  }
273
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 {
280
+ defaultPool = null;
281
+ }
282
+
274
283
  // --------------------------------------------------------------------------
275
284
  // Client
276
285
  // --------------------------------------------------------------------------
@@ -307,6 +316,20 @@ export class FluxClient {
307
316
  private lastContent = "";
308
317
  private contentDone = false;
309
318
 
319
+ // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
320
+ // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
321
+ // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
322
+ // renders once per frame instead of once per patch (the DOM renderer already
323
+ // batches to 1/frame independently). Lossless: committed blocks are
324
+ // reference-stable, so a dropped intermediate notify only skips a tail-only
325
+ // render that the next flush supersedes. The finalize/done patch is exempt and
326
+ // flushes synchronously, never deferred to the next frame.
327
+ private coalesce = false;
328
+ private rafHandle: number | null = null;
329
+ // Set by finalize(); the next patch's emit flushes synchronously (a 'done'
330
+ // notification must not be deferred a frame) and clears it.
331
+ private finalizePending = false;
332
+
310
333
  // Perf
311
334
  private appendedBytes = 0;
312
335
  private patchCount = 0;
@@ -315,6 +338,11 @@ export class FluxClient {
315
338
  private firstAppendMs = 0;
316
339
  private retainedBytes = 0;
317
340
  private wasmMemoryBytes = 0;
341
+ // Render-path observability (advanced ONLY when an onRenderMetrics hook is
342
+ // wired into a renderer; zero-cost otherwise). renderCount = React BlockView
343
+ // body renders; rebuildCount = DOM node rebuilds.
344
+ private renderCount = 0;
345
+ private rebuildCount = 0;
318
346
 
319
347
  /**
320
348
  * @param options.pool worker pool to join (defaults to the shared
@@ -328,6 +356,14 @@ export class FluxClient {
328
356
  * order, after the store updates) — for side effects like lazily
329
357
  * highlighting a finished code block or analytics. A committed block never
330
358
  * re-fires; the streaming tail does not (subscribe for live tail updates).
359
+ * @param options.coalesce opt-in (default `false`): collapse multiple
360
+ * intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
361
+ * flush to subscribers, so a React `useSyncExternalStore` consumer renders at
362
+ * most once per frame instead of once per patch. Lossless — committed blocks
363
+ * are reference-stable, so only superseded tail-only renders are skipped. The
364
+ * stream-completion (finalize) patch always flushes synchronously, and a
365
+ * pending frame is cancelled on `reset()`/`destroy()`. No effect when
366
+ * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
331
367
  */
332
368
  constructor(
333
369
  options: {
@@ -335,12 +371,14 @@ export class FluxClient {
335
371
  config?: ParserConfig;
336
372
  onError?: (err: { message: string; fatal?: boolean }) => void;
337
373
  onBlock?: (block: Block) => void;
374
+ coalesce?: boolean;
338
375
  } = {},
339
376
  ) {
340
377
  this.pool = options.pool ?? getDefaultPool();
341
378
  this.config = options.config;
342
379
  this.onError = options.onError;
343
380
  this.onBlock = options.onBlock;
381
+ this.coalesce = options.coalesce ?? false;
344
382
  }
345
383
 
346
384
  /**
@@ -392,6 +430,9 @@ export class FluxClient {
392
430
 
393
431
  finalize() {
394
432
  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
+ this.finalizePending = true;
395
436
  this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
396
437
  }
397
438
 
@@ -514,6 +555,11 @@ export class FluxClient {
514
555
  }
515
556
 
516
557
  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
+ const hadContent = this.store.snapshot.length > 0;
517
563
  this.store = emptyBlockStore();
518
564
  this.appendedBytes = 0;
519
565
  this.patchCount = 0;
@@ -524,10 +570,14 @@ export class FluxClient {
524
570
  this.wasmMemoryBytes = 0;
525
571
  this.lastContent = ""; // setContent baseline: the worker drops the parser here
526
572
  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
+ this.cancelFrame();
576
+ this.finalizePending = false;
527
577
  // Same streamId + worker — the worker frees and lazily recreates the parser.
528
578
  const pw = this.ensureAcquired();
529
579
  this.pool.send(pw, { type: "reset", streamId: this.streamId });
530
- this.emit();
580
+ if (hadContent) this.emit(true); // clear-the-view notify is synchronous
531
581
  }
532
582
 
533
583
  destroy() {
@@ -538,6 +588,9 @@ export class FluxClient {
538
588
  // We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
539
589
  // on the SAME instance needs the same pw/streamId to re-register.
540
590
  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
+ this.cancelFrame();
593
+ this.finalizePending = false;
541
594
  this.listeners.clear();
542
595
  this.attached = false;
543
596
  }
@@ -578,6 +631,25 @@ export class FluxClient {
578
631
 
579
632
  getSnapshot = (): Block[] => this.store.snapshot;
580
633
 
634
+ /**
635
+ * Internal: a renderer with an `onRenderMetrics` hook calls this once per
636
+ * actual React block render so `getMetrics().renderCount` aggregates churn.
637
+ * No-op cost when no hook is wired (it is simply never called). Not part of
638
+ * the public API surface — the underscore marks it renderer-internal.
639
+ */
640
+ __noteRender(): void {
641
+ this.renderCount++;
642
+ }
643
+
644
+ /**
645
+ * Internal: the DOM renderer calls this once per actual node rebuild (the
646
+ * changed-block branch) when an `onRenderMetrics` hook is wired, so
647
+ * `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
648
+ */
649
+ __noteRebuild(): void {
650
+ this.rebuildCount++;
651
+ }
652
+
581
653
  getMetrics() {
582
654
  const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
583
655
  return {
@@ -594,6 +666,11 @@ export class FluxClient {
594
666
  // clients on the same worker report the same number. Use Math.max (not
595
667
  // sum) when aggregating across clients; summing double-counts.
596
668
  wasmMemoryBytes: this.wasmMemoryBytes,
669
+ // Render-path churn (0 unless an onRenderMetrics hook is wired into a
670
+ // renderer): renderCount = React block-body renders, rebuildCount = DOM
671
+ // node rebuilds. Committed blocks memo-skip, so they contribute once.
672
+ renderCount: this.renderCount,
673
+ rebuildCount: this.rebuildCount,
597
674
  };
598
675
  }
599
676
 
@@ -644,7 +721,11 @@ export class FluxClient {
644
721
  this.wasmMemoryBytes = msg.wasmMemoryBytes;
645
722
  this.patchCount += 1;
646
723
  this.lastPatchMs = performance.now();
647
- this.emit();
724
+ // The post-finalize (done) patch must notify synchronously — never defer
725
+ // stream completion to the next frame. One-shot: cleared after use.
726
+ const sync = this.finalizePending;
727
+ this.finalizePending = false;
728
+ this.emit(sync);
648
729
  // After subscribers see the new snapshot, fire the per-block hook for
649
730
  // anything that just committed (document order). A throw here is
650
731
  // isolated by the pool's dispatch boundary and won't skip emit().
@@ -663,7 +744,38 @@ export class FluxClient {
663
744
  }
664
745
  }
665
746
 
666
- private emit() {
747
+ /**
748
+ * Notify subscribers of a new snapshot.
749
+ *
750
+ * With `coalesce` off (default) this is fully synchronous, exactly as before.
751
+ * With it on and `requestAnimationFrame` available, a normal emit only
752
+ * *schedules* a single per-frame flush — repeated intra-frame emits collapse
753
+ * into one notify. `sync` forces an immediate flush (stream completion / reset)
754
+ * and cancels any frame already pending so the snapshot is delivered once.
755
+ */
756
+ private emit(sync = false) {
757
+ if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
758
+ if (this.rafHandle !== null) return; // a flush is already scheduled this frame
759
+ this.rafHandle = requestAnimationFrame(() => {
760
+ this.rafHandle = null;
761
+ this.flushNow();
762
+ });
763
+ return;
764
+ }
765
+ // Synchronous path: drop any pending frame so its notify can't double-fire
766
+ // after this one, then flush immediately.
767
+ this.cancelFrame();
768
+ this.flushNow();
769
+ }
770
+
771
+ private flushNow() {
667
772
  for (const fn of this.listeners) fn();
668
773
  }
774
+
775
+ private cancelFrame() {
776
+ if (this.rafHandle !== null) {
777
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
778
+ this.rafHandle = null;
779
+ }
780
+ }
669
781
  }