flux-md 0.15.0 → 0.15.1
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 +65 -0
- package/README.md +37 -0
- package/package.json +1 -1
- package/src/client.ts +15 -1
- package/src/element.ts +9 -1
- package/src/hi.ts +5 -2
- package/src/html-to-react.ts +28 -3
- package/src/react.tsx +33 -3
- package/src/server.tsx +5 -5
- package/src/wasm/flux_md_core_bg.wasm +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,71 @@ 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.15.1 — 2026-06-22
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **XSS — dangerous-scheme autolinks are neutralized.** A CommonMark URI autolink
|
|
12
|
+
(`<javascript:alert(1)>`, `<vbscript:…>`, `<file:…>`) previously emitted a live
|
|
13
|
+
`href`, because autolinks bypassed the scheme allowlist that regular links go
|
|
14
|
+
through. They now route through the same decode-stable dangerous-scheme filter:
|
|
15
|
+
the `href` becomes `#` while the visible link text is unchanged. `file:` is now
|
|
16
|
+
blocked everywhere (links, autolinks, URL attributes) — it has no legitimate use
|
|
17
|
+
in rendered untrusted markdown and is a local-resource / phishing vector in
|
|
18
|
+
privileged contexts (Electron, extensions, `file://` origins).
|
|
19
|
+
- **Component-tag / `htmlToReact` attribute hardening.** Sanitized attributes now
|
|
20
|
+
also drop React-meaningful names (`dangerouslySetInnerHTML`, `ref`, `key`,
|
|
21
|
+
`defaultValue`, `defaultChecked`, `suppressHydrationWarning`, …) so a hostile
|
|
22
|
+
attribute can't crash the render tree or smuggle in a prop. Attribute→prop
|
|
23
|
+
lookup maps are prototype-free (`Object.create(null)`), and only HTML / `data-`
|
|
24
|
+
/ `aria-` attribute names are forwarded to React.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **ReDoS / quadratic blow-ups on untrusted input.**
|
|
29
|
+
- Highlighter (`hi.ts`): the JS/TS regex-literal and bash double-quoted-string
|
|
30
|
+
patterns could backtrack quadratically on crafted code blocks; both rewritten
|
|
31
|
+
to linear forms, plus a 50 KB per-block size guard.
|
|
32
|
+
- URL scheme check: the decode-to-fixpoint loop (Rust `is_dangerous_scheme` and
|
|
33
|
+
JS `safeUrl`) is capped at 8 passes — still catches multi-encoded
|
|
34
|
+
`javascript&amp;#58;` payloads, no longer O(n²) on `&`-spam.
|
|
35
|
+
- Inline parser: nested / unbalanced link-bracket scanning is bounded
|
|
36
|
+
(depth + length caps); GFM extended-autolink trailing-paren trimming is now
|
|
37
|
+
linear instead of recounting the span each iteration.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **`flux-md/server` uses a literal `import("node:fs/promises")`** instead of a
|
|
42
|
+
variable specifier, resolving the `dynamicRequire` supply-chain signal. Behavior
|
|
43
|
+
is unchanged — still a Node-only, `file:`-guarded branch.
|
|
44
|
+
- Added a **`## Security`** / supply-chain-transparency section to the README and a
|
|
45
|
+
documented **`socket.yml`** covering the inherent `nativeCode` / `networkAccess`
|
|
46
|
+
/ `filesystemAccess` signals (the WebAssembly core and the opt-in
|
|
47
|
+
`<flux-markdown src>` fetch).
|
|
48
|
+
|
|
49
|
+
### Performance
|
|
50
|
+
|
|
51
|
+
- **No redundant re-renders / rebuilds on no-op updates.**
|
|
52
|
+
- `<flux-markdown>` ignores a `setAttribute` whose value didn't change (a host
|
|
53
|
+
framework re-applying identical attributes no longer tears down the self-owned
|
|
54
|
+
client and reparses the whole document), and the `components` / `sanitize`
|
|
55
|
+
property setters skip the remount when assigned the same identity.
|
|
56
|
+
- `FluxClient.reset()` no longer notifies subscribers when the store was already
|
|
57
|
+
empty — skips a wasted, output-identical render pass.
|
|
58
|
+
- Documented that `sanitize` (like `components`) should be memoized/hoisted in
|
|
59
|
+
React, so a fresh closure each render doesn't bust the per-block memo.
|
|
60
|
+
- Added render-count / node-reuse / no-remount regression tests across the React,
|
|
61
|
+
DOM, store, custom-element, and Vue bindings, locking in that committed blocks
|
|
62
|
+
never re-render or rebuild as the stream grows (only the streaming tail does).
|
|
63
|
+
|
|
64
|
+
### Known limitations
|
|
65
|
+
|
|
66
|
+
- Streaming a single very large **unclosed** block (a multi-megabyte indented code
|
|
67
|
+
block, open HTML block, or footnote-disarmed list delivered across many chunks)
|
|
68
|
+
is still O(n²) in the uncommitted-tail length. A bounded incremental cache for
|
|
69
|
+
these resumable containers is tracked as follow-up; finalized / closed blocks and
|
|
70
|
+
all other inputs are unaffected.
|
|
71
|
+
|
|
7
72
|
## 0.15.0 — 2026-06-17
|
|
8
73
|
|
|
9
74
|
### Added
|
package/README.md
CHANGED
|
@@ -995,6 +995,43 @@ genuinely hostile content where CSS-overlay/clickjacking matters, render inside
|
|
|
995
995
|
a sandboxed `<iframe>` instead — sanitization stops injection, not every
|
|
996
996
|
visual-overlay trick.
|
|
997
997
|
|
|
998
|
+
### Supply-chain transparency
|
|
999
|
+
|
|
1000
|
+
flux-md is **zero runtime dependency** — no third-party packages are pulled in
|
|
1001
|
+
at runtime. The parsing core is Rust compiled to WebAssembly, reproducibly
|
|
1002
|
+
buildable from `crates/flux-md-core/` via `bun run build:wasm`.
|
|
1003
|
+
|
|
1004
|
+
**Native code (WebAssembly).** The shipped `flux_md_core_bg.wasm` (~200 KB) is
|
|
1005
|
+
first-party, built from the Rust source in this repo, and runs inside a sandboxed
|
|
1006
|
+
Web Worker (browser) or Node worker thread. Supply-chain scanners such as
|
|
1007
|
+
[Socket.dev](https://socket.dev) will flag it as `nativeCode` — this is accurate
|
|
1008
|
+
and expected. The WASM is not a vendored third-party binary; it is reproducible
|
|
1009
|
+
from source.
|
|
1010
|
+
|
|
1011
|
+
**Network access.** flux-md performs network I/O in exactly two scenarios, both
|
|
1012
|
+
caller-driven:
|
|
1013
|
+
|
|
1014
|
+
- `<flux-markdown src="URL">` — the Web Component fetches the URL you supply and
|
|
1015
|
+
streams the response. No URL is ever chosen by flux-md itself.
|
|
1016
|
+
- The wasm-bindgen glue (`wasm/flux_md_core.js`) loads the co-located `.wasm`
|
|
1017
|
+
asset via `fetch(new URL("…_bg.wasm", import.meta.url))` — bundlers resolve
|
|
1018
|
+
this to a local build artifact, not a remote endpoint.
|
|
1019
|
+
|
|
1020
|
+
flux-md has no telemetry, no analytics, and no first-party remote endpoints.
|
|
1021
|
+
Socket will flag the `networkAccess` signal — it is accurate and expected. In
|
|
1022
|
+
privileged contexts (browser extensions, Electron, environments where the
|
|
1023
|
+
same-origin policy may not apply), treat the `src` attribute value as you would
|
|
1024
|
+
any external URL and allowlist it in your CSP / security policy.
|
|
1025
|
+
|
|
1026
|
+
**Filesystem access (Node/SSR only).** `flux-md/server` reads the package's
|
|
1027
|
+
own `.wasm` file off disk on Node.js (Node's `fetch` cannot load `file://`
|
|
1028
|
+
URLs). This is a Node-only path; it reads only the package-internal asset and
|
|
1029
|
+
never touches caller-supplied paths. Socket will flag `filesystemAccess` — also
|
|
1030
|
+
accurate and expected.
|
|
1031
|
+
|
|
1032
|
+
The `socket.yml` at the repository root documents these signals with their
|
|
1033
|
+
justifications for Socket's GitHub app.
|
|
1034
|
+
|
|
998
1035
|
## Scaling
|
|
999
1036
|
|
|
1000
1037
|
`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.
|
|
3
|
+
"version": "0.15.1",
|
|
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
|
@@ -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
|
// --------------------------------------------------------------------------
|
|
@@ -514,6 +523,11 @@ export class FluxClient {
|
|
|
514
523
|
}
|
|
515
524
|
|
|
516
525
|
reset() {
|
|
526
|
+
// Only notify subscribers if there was content to clear: resetting an
|
|
527
|
+
// already-empty store leaves the view empty either way, so skip the no-op
|
|
528
|
+
// emit (which would otherwise drive every subscriber through a wasted,
|
|
529
|
+
// output-identical render pass).
|
|
530
|
+
const hadContent = this.store.snapshot.length > 0;
|
|
517
531
|
this.store = emptyBlockStore();
|
|
518
532
|
this.appendedBytes = 0;
|
|
519
533
|
this.patchCount = 0;
|
|
@@ -527,7 +541,7 @@ export class FluxClient {
|
|
|
527
541
|
// Same streamId + worker — the worker frees and lazily recreates the parser.
|
|
528
542
|
const pw = this.ensureAcquired();
|
|
529
543
|
this.pool.send(pw, { type: "reset", streamId: this.streamId });
|
|
530
|
-
this.emit();
|
|
544
|
+
if (hadContent) this.emit();
|
|
531
545
|
}
|
|
532
546
|
|
|
533
547
|
destroy() {
|
package/src/element.ts
CHANGED
|
@@ -90,6 +90,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
90
90
|
return this.#components;
|
|
91
91
|
}
|
|
92
92
|
set components(value: DomComponents | undefined) {
|
|
93
|
+
if (value === this.#components) return; // no-op re-assign: don't remount
|
|
93
94
|
this.#components = value;
|
|
94
95
|
if (this.#connected) this.#remount();
|
|
95
96
|
}
|
|
@@ -98,6 +99,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
98
99
|
return this.#sanitize;
|
|
99
100
|
}
|
|
100
101
|
set sanitize(value: ((html: string) => string) | undefined) {
|
|
102
|
+
if (value === this.#sanitize) return; // no-op re-assign: don't remount
|
|
101
103
|
this.#sanitize = value;
|
|
102
104
|
if (this.#connected) this.#remount();
|
|
103
105
|
}
|
|
@@ -155,10 +157,16 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
attributeChangedCallback(name: string,
|
|
160
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
|
159
161
|
// attributeChangedCallback fires before connectedCallback for attributes
|
|
160
162
|
// present at upgrade; ignore until connected so config reads happen once.
|
|
161
163
|
if (!this.#connected) return;
|
|
164
|
+
// setAttribute fires this on EVERY set, including setting an attribute to
|
|
165
|
+
// its current value (common when a host framework re-applies the same
|
|
166
|
+
// attrs on re-render). A no-op value change must not tear down the client
|
|
167
|
+
// and reparse the whole document — only a genuine change proceeds.
|
|
168
|
+
// (Attribute removal yields null, distinct from an empty string.)
|
|
169
|
+
if (oldValue === newValue) return;
|
|
162
170
|
|
|
163
171
|
if (name === "markdown" || name === "src") {
|
|
164
172
|
// One-shot content source change — only for a self-owned client. A
|
package/src/hi.ts
CHANGED
|
@@ -55,7 +55,7 @@ const jsPats: Pat[] = [
|
|
|
55
55
|
["str", /"(?:\\.|[^"\\\n])*"/y],
|
|
56
56
|
["str", /'(?:\\.|[^'\\\n])*'/y],
|
|
57
57
|
["str", /`(?:\\.|[^`\\])*`/y],
|
|
58
|
-
["rx", /\/(
|
|
58
|
+
["rx", /\/(?![*/])(?:\\.|[^/\\\n])+\/[gimsuy]*/y],
|
|
59
59
|
["num", /\b(?:0x[\da-fA-F_]+|0b[01_]+|0o[0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)\b/y],
|
|
60
60
|
["ident", /[A-Za-z_$][\w$]*/y],
|
|
61
61
|
["pun", /[+\-*/=<>!&|^~?:;,.[\](){}]/y],
|
|
@@ -103,7 +103,7 @@ const goPats: Pat[] = [
|
|
|
103
103
|
|
|
104
104
|
const bashPats: Pat[] = [
|
|
105
105
|
["com", /#[^\n]*/y],
|
|
106
|
-
["str", /"(
|
|
106
|
+
["str", /"(?:\\.|[^"\\])*"/y],
|
|
107
107
|
["str", /'[^']*'/y],
|
|
108
108
|
["var", /\$\{[^}]+\}|\$\w+|\$[*@#?!$0-9]/y],
|
|
109
109
|
["num", /\b\d+\b/y],
|
|
@@ -187,6 +187,9 @@ function escapeHtml(s: string): string {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
export function highlight(code: string, lang: string): string {
|
|
190
|
+
// Defense-in-depth: never tokenize a pathologically huge block on the main
|
|
191
|
+
// thread — fall back to plain escaped text.
|
|
192
|
+
if (code.length > 50_000) return escapeHtml(code);
|
|
190
193
|
const conf = LANGS[lang.toLowerCase()];
|
|
191
194
|
if (!conf) return escapeHtml(code);
|
|
192
195
|
|
package/src/html-to-react.ts
CHANGED
|
@@ -10,7 +10,10 @@ const VOID = new Set([
|
|
|
10
10
|
// Attribute name → React prop name, for the handful that differ. Anything not
|
|
11
11
|
// listed passes through verbatim (React forwards data-*/aria-* and lowercase
|
|
12
12
|
// attributes unchanged).
|
|
13
|
-
|
|
13
|
+
// Prototype-free map so an attribute named `constructor`/`hasOwnProperty`/etc.
|
|
14
|
+
// returns undefined (and the `?? name` fallback fires) rather than resolving to
|
|
15
|
+
// an inherited Object.prototype member.
|
|
16
|
+
const ATTR_MAP: Record<string, string> = Object.assign(Object.create(null), {
|
|
14
17
|
class: "className",
|
|
15
18
|
for: "htmlFor",
|
|
16
19
|
colspan: "colSpan",
|
|
@@ -26,7 +29,7 @@ const ATTR_MAP: Record<string, string> = {
|
|
|
26
29
|
crossorigin: "crossOrigin",
|
|
27
30
|
enterkeyhint: "enterKeyHint",
|
|
28
31
|
inputmode: "inputMode",
|
|
29
|
-
};
|
|
32
|
+
});
|
|
30
33
|
|
|
31
34
|
// URL-bearing attributes whose value must be scheme-checked. `htmlToReact` is
|
|
32
35
|
// exported and may be handed untrusted HTML directly; React happily renders a
|
|
@@ -34,6 +37,19 @@ const ATTR_MAP: Record<string, string> = {
|
|
|
34
37
|
// defense-in-depth — the core's own output is already sanitized.
|
|
35
38
|
const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction", "action", "poster", "data"]);
|
|
36
39
|
|
|
40
|
+
// React-meaningful prop names that must never be forwarded from (possibly
|
|
41
|
+
// untrusted) HTML attributes: `dangerouslySetInnerHTML` as a prop crashes the
|
|
42
|
+
// whole render tree (DoS), and ref/key/defaultValue/etc. are injectable.
|
|
43
|
+
const PROP_DENY = new Set([
|
|
44
|
+
"dangerouslysetinnerhtml", "ref", "key", "defaultvalue", "defaultchecked",
|
|
45
|
+
"suppresshydrationwarning", "suppresscontenteditablewarning",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Only forward attribute names that are a plain HTML attribute identifier
|
|
49
|
+
// (so camelCase / `__proto__` / `constructor` never reach React props). The
|
|
50
|
+
// explicit ATTR_MAP renames and `xlink:href` are allowed past this gate.
|
|
51
|
+
const SAFE_ATTR_NAME = /^[a-z][a-z0-9-]*$/i;
|
|
52
|
+
|
|
37
53
|
/** Replace a dangerous-scheme URL with "#". Mirrors the Rust `is_dangerous_scheme`:
|
|
38
54
|
* strip control chars (C0, DEL, C1 — matching Rust char::is_control),
|
|
39
55
|
* lowercase, then match. The strip affects only the probe, never output. */
|
|
@@ -42,8 +58,10 @@ function safeUrl(value: string): string {
|
|
|
42
58
|
// reaches the DOM, so peel layers to a fixpoint before the scheme check —
|
|
43
59
|
// catches `javascript:` and double-encoded `javascript&#58;`. Only the
|
|
44
60
|
// probe is decoded; the returned value is untouched (safe URLs stay verbatim).
|
|
61
|
+
// Cap at 8 iterations: far beyond any legit URL (browsers entity-decode an
|
|
62
|
+
// href once), and bounds the loop so a hostile value can't make it quadratic.
|
|
45
63
|
let decoded = value;
|
|
46
|
-
for (let prev = ""; decoded !== prev; ) {
|
|
64
|
+
for (let i = 0, prev = ""; i < 8 && decoded !== prev; i++) {
|
|
47
65
|
prev = decoded;
|
|
48
66
|
decoded = decodeEntities(decoded);
|
|
49
67
|
}
|
|
@@ -265,6 +283,9 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
|
|
|
265
283
|
// React drops most lowercase `on*` attrs — this also covers casings and
|
|
266
284
|
// future React behavior.
|
|
267
285
|
if (lower.startsWith("on")) continue;
|
|
286
|
+
// Reject React-meaningful names that would crash the render tree or inject
|
|
287
|
+
// internals (dangerouslySetInnerHTML, ref, key, defaultValue, …).
|
|
288
|
+
if (PROP_DENY.has(lower)) continue;
|
|
268
289
|
if (lower === "style" && typeof value === "string") {
|
|
269
290
|
props.style = safeStyle(parseStyle(value));
|
|
270
291
|
continue;
|
|
@@ -280,6 +301,10 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
|
|
|
280
301
|
props.defaultChecked = value === true ? true : value;
|
|
281
302
|
continue;
|
|
282
303
|
}
|
|
304
|
+
// Restrict forwarded ORIGINAL names to a plain HTML attribute identifier
|
|
305
|
+
// (plus the ATTR_MAP renames and xlink:href handled above) so weird casings
|
|
306
|
+
// / `__proto__` / `constructor` can never become a React prop.
|
|
307
|
+
if (!(lower in ATTR_MAP) && !SAFE_ATTR_NAME.test(name)) continue;
|
|
283
308
|
props[ATTR_MAP[lower] ?? name] = value;
|
|
284
309
|
}
|
|
285
310
|
return props;
|
package/src/react.tsx
CHANGED
|
@@ -100,6 +100,10 @@ interface FluxMarkdownProps {
|
|
|
100
100
|
* `unsafeHtml` on. flux-md stays zero-dep — you bring the sanitizer. The
|
|
101
101
|
* built-in code/math renderers operate on already-escaped content and are not
|
|
102
102
|
* run through it. When omitted, rendering is byte-identical and zero-cost.
|
|
103
|
+
*
|
|
104
|
+
* **Memoize / hoist this** (same trap as `components`): a fresh closure each
|
|
105
|
+
* render busts the per-block memo, so every block re-sanitizes and re-parses
|
|
106
|
+
* on every patch instead of only the streaming tail.
|
|
103
107
|
*/
|
|
104
108
|
sanitize?: (html: string) => string;
|
|
105
109
|
/** Appended to the root's `className` (the `flux-md` class is always present). */
|
|
@@ -362,14 +366,40 @@ export function blockKindProps(block: Block, components?: Components): BlockComp
|
|
|
362
366
|
return props;
|
|
363
367
|
}
|
|
364
368
|
|
|
365
|
-
|
|
369
|
+
// Prototype-free so a key like `constructor`/`hasOwnProperty` returns undefined
|
|
370
|
+
// (and the `?? k` fallback fires) instead of an inherited Object.prototype member.
|
|
371
|
+
const REACT_ATTR_NAME: Record<string, string> = Object.assign(Object.create(null), {
|
|
372
|
+
class: "className",
|
|
373
|
+
for: "htmlFor",
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// React-meaningful prop names that must never survive into a user override's
|
|
377
|
+
// attrs object (dangerouslySetInnerHTML crashes the render tree; ref/key/etc.
|
|
378
|
+
// inject internals). Mirrors html-to-react's PROP_DENY.
|
|
379
|
+
const ATTR_DENY = new Set([
|
|
380
|
+
"dangerouslysetinnerhtml", "ref", "key", "defaultvalue", "defaultchecked",
|
|
381
|
+
"suppresshydrationwarning", "suppresscontenteditablewarning",
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
// Forward only plain HTML attribute identifiers (the REACT_ATTR_NAME renames
|
|
385
|
+
// pass too), so weird casings / `__proto__` / `constructor` never reach a prop.
|
|
386
|
+
const SAFE_ATTR_NAME = /^[a-z][a-z0-9-]*$/i;
|
|
366
387
|
|
|
367
388
|
/** Convert sanitized HTML attribute pairs into a React-spreadable object,
|
|
368
389
|
* renaming the two names React requires (`class`→`className`, `for`→`htmlFor`).
|
|
369
|
-
* Other names (including `data-*` / `aria-*`) pass through unchanged.
|
|
390
|
+
* Other names (including `data-*` / `aria-*`) pass through unchanged. Drops
|
|
391
|
+
* inline event handlers and React-meaningful/unsafe names as defense-in-depth
|
|
392
|
+
* (the Rust `sanitize_attrs` is the primary gate; this keeps the React layer
|
|
393
|
+
* safe on its own when attrs are handed to user override components). */
|
|
370
394
|
function reactAttrs(pairs: [string, string][]): Record<string, string> {
|
|
371
395
|
const out: Record<string, string> = {};
|
|
372
|
-
for (const [k, v] of pairs)
|
|
396
|
+
for (const [k, v] of pairs) {
|
|
397
|
+
const lower = k.toLowerCase();
|
|
398
|
+
if (lower.startsWith("on")) continue;
|
|
399
|
+
if (ATTR_DENY.has(lower)) continue;
|
|
400
|
+
if (!(lower in REACT_ATTR_NAME) && !SAFE_ATTR_NAME.test(k)) continue;
|
|
401
|
+
out[REACT_ATTR_NAME[lower] ?? k] = v;
|
|
402
|
+
}
|
|
373
403
|
return out;
|
|
374
404
|
}
|
|
375
405
|
|
package/src/server.tsx
CHANGED
|
@@ -58,11 +58,11 @@ export function initFlux(opts?: { wasm?: BufferSource | WebAssembly.Module }): P
|
|
|
58
58
|
initPromise = (async () => {
|
|
59
59
|
const wasmUrl = new URL("./wasm/flux_md_core_bg.wasm", import.meta.url);
|
|
60
60
|
if (wasmUrl.protocol === "file:") {
|
|
61
|
-
// Node: read the bytes (Node's fetch can't load file://).
|
|
62
|
-
//
|
|
63
|
-
// (
|
|
64
|
-
|
|
65
|
-
const { readFile } = await import(
|
|
61
|
+
// Node: read the bytes (Node's fetch can't load file://). The literal
|
|
62
|
+
// `node:` specifier is externalized by bundlers, so node:fs never reaches
|
|
63
|
+
// a web bundle (this branch is also file:-only, never true in browsers).
|
|
64
|
+
// @ts-ignore — no @types/node in this package; node:fs/promises is a builtin.
|
|
65
|
+
const { readFile } = await import("node:fs/promises");
|
|
66
66
|
initFluxSync(await readFile(wasmUrl));
|
|
67
67
|
} else {
|
|
68
68
|
await initWasmAsync({ module_or_path: wasmUrl });
|
|
Binary file
|