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/CHANGELOG.md +64 -0
- package/README.md +185 -7
- package/package.json +4 -3
- package/src/client.ts +74 -0
- package/src/index.ts +1 -1
- package/src/react.tsx +63 -5
- package/src/solid.tsx +72 -2
- package/src/styles.css +182 -0
- package/src/svelte.ts +100 -1
- package/src/vue.ts +58 -1
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/package.json +1 -1
- package/src/worker-core.ts +174 -0
- package/src/worker.ts +39 -136
package/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
|
|
2
|
+
import { FluxClient } from "./client";
|
|
3
|
+
import type { ParserConfig } from "./types-core";
|
|
3
4
|
import { mountFluxMarkdown, type DomComponents, type MountOptions } from "./dom";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -53,3 +54,101 @@ export function fluxMarkdown(
|
|
|
53
54
|
},
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Controlled-string sibling of {@link fluxMarkdown}: instead of taking a
|
|
60
|
+
* caller-owned client, this action OWNS a single {@link FluxClient} (constructed
|
|
61
|
+
* from `config`) and drives it from a CONTROLLED full string — the bridge for
|
|
62
|
+
* Svelte UIs that hold a streaming message as one growing `content` prop rather
|
|
63
|
+
* than feeding the client by hand. Each update passes the whole document-so-far
|
|
64
|
+
* and {@link FluxClient.setContent} diffs it: a prefix-extension appends only the
|
|
65
|
+
* delta; any divergence resets and reparses.
|
|
66
|
+
*
|
|
67
|
+
* ```svelte
|
|
68
|
+
* <div use:fluxMarkdownString={{ content, streaming: !done }} />
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* Pass `streaming: false` once the content is final to finalize the stream and
|
|
72
|
+
* commit its last block (only then does a finished code fence highlight + show
|
|
73
|
+
* its copy button). When `streaming` is omitted or `true` the stream is left
|
|
74
|
+
* OPEN — right for a still-growing string, but a *complete static* string keeps
|
|
75
|
+
* its last block in the streaming state until you pass `{ streaming: false }`.
|
|
76
|
+
* (Inferring "done" from an absent flag is deliberately avoided — it would
|
|
77
|
+
* re-finalize on every token and trip an O(n²) reparse.)
|
|
78
|
+
*
|
|
79
|
+
* SSR-safe by construction: a Svelte action runs ONLY in the browser, and the
|
|
80
|
+
* `FluxClient` constructor is worker-free — the first worker is spawned lazily by
|
|
81
|
+
* `setContent`, which only runs here (never during a server render).
|
|
82
|
+
*
|
|
83
|
+
* Lifecycle differs from {@link fluxMarkdown}: this action constructs the client
|
|
84
|
+
* once (a later `config` change is ignored, like a created-once instance) and
|
|
85
|
+
* `destroy()`s it on teardown — it OWNS the client. The mount-option reconcile
|
|
86
|
+
* (`components`/`sanitize`/`virtualize`/`stickToBottom`) matches `fluxMarkdown`,
|
|
87
|
+
* but the remount reuses the SAME client so its `setContent` diff baseline
|
|
88
|
+
* survives.
|
|
89
|
+
*/
|
|
90
|
+
export interface FluxMarkdownStringParams extends Omit<FluxMarkdownParams, "client"> {
|
|
91
|
+
/** The full document-so-far. Diffed against the prior value on every update. */
|
|
92
|
+
content: string;
|
|
93
|
+
/** Leave the stream open while true/omitted; `false` finalizes (commits the tail). */
|
|
94
|
+
streaming?: boolean;
|
|
95
|
+
/** Per-stream parser flags. Applied once at construction; later changes are ignored. */
|
|
96
|
+
config?: ParserConfig;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Strip the action-only inputs (`content`/`streaming`/`config`), leaving the
|
|
100
|
+
* fields {@link mountFluxMarkdown} reads — so they never leak into the mount. */
|
|
101
|
+
function mountOptionsOf(p: FluxMarkdownStringParams): Omit<FluxMarkdownParams, "client"> {
|
|
102
|
+
const { content: _c, streaming: _s, config: _cfg, ...rest } = p;
|
|
103
|
+
void _c;
|
|
104
|
+
void _s;
|
|
105
|
+
void _cfg;
|
|
106
|
+
return rest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function fluxMarkdownString(
|
|
110
|
+
node: HTMLElement,
|
|
111
|
+
params: FluxMarkdownStringParams,
|
|
112
|
+
): ActionReturn<FluxMarkdownStringParams> {
|
|
113
|
+
// This action OWNS the client — construct it once from `config` (a later
|
|
114
|
+
// `config` change is ignored, mirroring the created-once React hook). The
|
|
115
|
+
// content/streaming diff baseline lives INSIDE the client (setContent), so we
|
|
116
|
+
// keep no outer copy; only the mount-option fields are tracked for the remount
|
|
117
|
+
// comparison.
|
|
118
|
+
let options = mountOptionsOf(params);
|
|
119
|
+
const client = new FluxClient({ config: params.config });
|
|
120
|
+
let handle = mountFluxMarkdown(client, node, options as MountOptions);
|
|
121
|
+
// First worker-bound op: spawns the lazy Worker — browser-only, never SSR.
|
|
122
|
+
client.setContent(params.content, { done: params.streaming === false });
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
update(next: FluxMarkdownStringParams) {
|
|
126
|
+
// Content/streaming are the primary changing inputs, so reconcile them on
|
|
127
|
+
// EVERY update — setContent self-no-ops when the string is unchanged, so
|
|
128
|
+
// this is cheap. (Unlike fluxMarkdown, we cannot early-return: that would
|
|
129
|
+
// swallow content updates.)
|
|
130
|
+
client.setContent(next.content, { done: next.streaming === false });
|
|
131
|
+
|
|
132
|
+
// Then reconcile mount options exactly like fluxMarkdown: remount only when
|
|
133
|
+
// a field the renderer reads actually changed identity, and reuse the SAME
|
|
134
|
+
// client so its setContent diff baseline (lastContent) survives the remount.
|
|
135
|
+
if (
|
|
136
|
+
next.components === options.components &&
|
|
137
|
+
next.sanitize === options.sanitize &&
|
|
138
|
+
next.virtualize === options.virtualize &&
|
|
139
|
+
next.stickToBottom === options.stickToBottom
|
|
140
|
+
) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
handle.destroy();
|
|
144
|
+
options = mountOptionsOf(next);
|
|
145
|
+
handle = mountFluxMarkdown(client, node, options as MountOptions);
|
|
146
|
+
},
|
|
147
|
+
destroy() {
|
|
148
|
+
// This action OWNS the client (unlike fluxMarkdown) — tear down the mount
|
|
149
|
+
// AND destroy the client so its pool slot is freed.
|
|
150
|
+
handle.destroy();
|
|
151
|
+
client.destroy();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/vue.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineComponent, h, onMounted, onUnmounted, ref, watch } from "vue";
|
|
2
2
|
import type { PropType, Ref } from "vue";
|
|
3
|
-
import
|
|
3
|
+
import { FluxClient } from "./client";
|
|
4
|
+
import type { ParserConfig } from "./types-core";
|
|
4
5
|
import { mountFluxMarkdown, type DomComponents, type MountHandle, type MountOptions } from "./dom";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -98,3 +99,59 @@ export const FluxMarkdown = defineComponent({
|
|
|
98
99
|
return () => h("div", { ref: container });
|
|
99
100
|
},
|
|
100
101
|
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Own a {@link FluxClient} driven by a CONTROLLED full string — the Vue analogue
|
|
105
|
+
* of React's `useFluxMarkdownString`, for UIs that hold a streaming message as a
|
|
106
|
+
* single growing string (a `ref`/computed) rather than as a stream. Pass a getter
|
|
107
|
+
* for the whole document-so-far; on every change {@link FluxClient.setContent}
|
|
108
|
+
* diffs it and does the minimal work (prefix-extension appends only the delta;
|
|
109
|
+
* any divergence resets and reparses).
|
|
110
|
+
*
|
|
111
|
+
* Pass `streaming: false` (via `getOptions`) once the content is final to
|
|
112
|
+
* finalize the stream and commit its last block. If `streaming` is omitted or
|
|
113
|
+
* `true` the stream is left OPEN — inferring "done" from an absent flag is
|
|
114
|
+
* deliberately avoided (it would re-finalize on every token for callers that
|
|
115
|
+
* grow the string without the flag — an O(n²) reparse trap). `config` is read
|
|
116
|
+
* once at construction and is immutable thereafter, so it is not a change
|
|
117
|
+
* trigger.
|
|
118
|
+
*
|
|
119
|
+
* **Returns the owned client** — a deliberate divergence from {@link useFluxMarkdown}
|
|
120
|
+
* (which returns `{ container }`). Mirroring React's hook, this composes with the
|
|
121
|
+
* component as `<FluxMarkdown :client="client" />` (and lets you read
|
|
122
|
+
* `outline()` / `getMetrics()` off it). The client is created in the composable
|
|
123
|
+
* body (constructor is worker-free → SSR-safe) and destroyed on unmount.
|
|
124
|
+
*
|
|
125
|
+
* SSR-safety: `setContent` is what spawns a Worker (via `append`), so it is
|
|
126
|
+
* called ONLY in `onMounted` and a NON-immediate `watch` — never during the
|
|
127
|
+
* server render path (`setup` constructs the client but neither lifecycle hook
|
|
128
|
+
* nor the non-immediate watch fires on the server).
|
|
129
|
+
*/
|
|
130
|
+
export function useFluxMarkdownString(
|
|
131
|
+
getContent: () => string,
|
|
132
|
+
getOptions?: () => { config?: ParserConfig; streaming?: boolean },
|
|
133
|
+
): FluxClient {
|
|
134
|
+
// One client per composable instance. Constructor is worker-free, so this is
|
|
135
|
+
// safe to run in setup() during SSR; config is read once and is immutable.
|
|
136
|
+
const client = new FluxClient({ config: getOptions?.()?.config });
|
|
137
|
+
|
|
138
|
+
// Reconcile the parser to the controlled string. setContent diffs internally,
|
|
139
|
+
// so this is correct whether `content` grows by a token or is swapped wholesale.
|
|
140
|
+
// `streaming === false` (never `!streaming`) → only an explicit false finalizes;
|
|
141
|
+
// an absent/true flag leaves the stream open.
|
|
142
|
+
const apply = (): void => {
|
|
143
|
+
client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Initial feed + every change. NOT { immediate: true }: an immediate watch runs
|
|
147
|
+
// in setup() — i.e. during SSR — and would spawn a Worker on the server. The
|
|
148
|
+
// initial feed is onMounted (client-only); the watch covers later changes.
|
|
149
|
+
onMounted(apply);
|
|
150
|
+
watch([getContent, () => getOptions?.()?.streaming], apply);
|
|
151
|
+
|
|
152
|
+
// This composable OWNS the client (unlike useFluxMarkdown, which takes one), so
|
|
153
|
+
// it destroys it here. Vue auto-stops the watcher on unmount.
|
|
154
|
+
onUnmounted(() => client.destroy());
|
|
155
|
+
|
|
156
|
+
return client;
|
|
157
|
+
}
|
|
Binary file
|
package/src/wasm/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { FromWorker, ParserConfig, Patch, ToWorker } from "./types-core";
|
|
2
|
+
|
|
3
|
+
/** The slice of `FluxParser` the worker drives — narrowed to an interface so the
|
|
4
|
+
* message/readiness state machine is unit-testable with a fake parser, no WASM.
|
|
5
|
+
* (Same testability move as {@link FluxPool} taking an injected worker factory.) */
|
|
6
|
+
export interface ParserLike {
|
|
7
|
+
append(chunk: string): Patch;
|
|
8
|
+
finalize(): Patch;
|
|
9
|
+
free(): void;
|
|
10
|
+
retainedBytes(): number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Dependencies injected into {@link WorkerCore}, isolating it from the worker
|
|
14
|
+
* globals (`self`, `queueMicrotask`) and the WASM module so it can be tested. */
|
|
15
|
+
export interface WorkerCoreDeps {
|
|
16
|
+
/** Create + configure a parser for a stream (prod: `new FluxParser()` + setX). */
|
|
17
|
+
makeParser(config: ParserConfig | undefined): ParserLike;
|
|
18
|
+
/** Post a message to the main thread (prod: `self.postMessage`). */
|
|
19
|
+
post(msg: FromWorker): void;
|
|
20
|
+
/** Current WASM heap size in bytes, reported on each patch (0 if unknown). */
|
|
21
|
+
memBytes(): number;
|
|
22
|
+
/** Defer a flush to a future microtask (prod: `queueMicrotask`). */
|
|
23
|
+
schedule(fn: () => void): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The worker's message reducer + WASM-readiness gate, extracted from the worker
|
|
28
|
+
* shell so its trickiest invariant is testable without a real Worker or WASM.
|
|
29
|
+
*
|
|
30
|
+
* **The invariant:** WASM `init()` is async, and the client does NOT wait for
|
|
31
|
+
* readiness before appending — so chunks can arrive first. A parser must never
|
|
32
|
+
* be constructed before init resolves (`new FluxParser()` against an
|
|
33
|
+
* uninitialized module throws `fluxparser_new of undefined` and silently drops
|
|
34
|
+
* that chunk). So while `ready` is false, appends only accumulate in `pending`
|
|
35
|
+
* (scheduleFlush is a no-op) and `finalize` is deferred; {@link markReady}
|
|
36
|
+
* drains both — appends first (creating each parser + applying buffered text),
|
|
37
|
+
* then any deferred finalize — once init has completed.
|
|
38
|
+
*/
|
|
39
|
+
export class WorkerCore {
|
|
40
|
+
// One parser per stream id; WASM is loaded once and shared by all of them.
|
|
41
|
+
private parsers = new Map<number, ParserLike>();
|
|
42
|
+
private config = new Map<number, ParserConfig>();
|
|
43
|
+
private pending = new Map<number, string>();
|
|
44
|
+
private totalAppended = new Map<number, number>();
|
|
45
|
+
private finalizePending = new Set<number>();
|
|
46
|
+
private flushScheduled = false;
|
|
47
|
+
private ready = false;
|
|
48
|
+
|
|
49
|
+
constructor(private deps: WorkerCoreDeps) {}
|
|
50
|
+
|
|
51
|
+
/** Handle one message from the main thread (append/finalize/reset/dispose). */
|
|
52
|
+
handle(msg: ToWorker): void {
|
|
53
|
+
const id = msg.streamId;
|
|
54
|
+
// Stash any per-stream config carried on the first message (FIFO guarantees
|
|
55
|
+
// it arrives before the parser is created in flush/finalize).
|
|
56
|
+
if ((msg.type === "append" || msg.type === "finalize") && msg.config) {
|
|
57
|
+
this.config.set(id, msg.config);
|
|
58
|
+
}
|
|
59
|
+
switch (msg.type) {
|
|
60
|
+
case "append":
|
|
61
|
+
this.pending.set(id, (this.pending.get(id) ?? "") + msg.chunk);
|
|
62
|
+
this.scheduleFlush();
|
|
63
|
+
break;
|
|
64
|
+
case "finalize":
|
|
65
|
+
// Before WASM is ready, defer: markReady() finalizes it after init (the
|
|
66
|
+
// buffered input is drained first). Otherwise finalize now.
|
|
67
|
+
if (!this.ready) this.finalizePending.add(id);
|
|
68
|
+
else this.doFinalize(id);
|
|
69
|
+
break;
|
|
70
|
+
case "reset":
|
|
71
|
+
// Free and recreate lazily on the next append — same stream id, **same
|
|
72
|
+
// config** (kept). The client resets its local state synchronously.
|
|
73
|
+
this.parsers.get(id)?.free();
|
|
74
|
+
this.parsers.delete(id);
|
|
75
|
+
this.pending.delete(id);
|
|
76
|
+
this.finalizePending.delete(id); // a reset cancels a not-yet-run early finalize
|
|
77
|
+
this.totalAppended.delete(id);
|
|
78
|
+
break;
|
|
79
|
+
case "dispose":
|
|
80
|
+
this.dispose(id);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Called once WASM init resolves: open the gate and drain what was buffered. */
|
|
86
|
+
markReady(): void {
|
|
87
|
+
this.ready = true;
|
|
88
|
+
this.deps.post({ type: "ready" });
|
|
89
|
+
// Order matters: flush appends first (creating each parser + applying
|
|
90
|
+
// buffered text), then finalize any stream that already requested it.
|
|
91
|
+
if (this.pending.size > 0) this.flush();
|
|
92
|
+
if (this.finalizePending.size > 0) {
|
|
93
|
+
for (const id of this.finalizePending) this.doFinalize(id);
|
|
94
|
+
this.finalizePending.clear();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private getOrCreate(streamId: number): ParserLike {
|
|
99
|
+
let p = this.parsers.get(streamId);
|
|
100
|
+
if (!p) {
|
|
101
|
+
p = this.deps.makeParser(this.config.get(streamId));
|
|
102
|
+
this.parsers.set(streamId, p);
|
|
103
|
+
}
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private dispose(streamId: number): void {
|
|
108
|
+
this.parsers.get(streamId)?.free();
|
|
109
|
+
this.parsers.delete(streamId);
|
|
110
|
+
this.config.delete(streamId);
|
|
111
|
+
this.pending.delete(streamId);
|
|
112
|
+
this.finalizePending.delete(streamId);
|
|
113
|
+
this.totalAppended.delete(streamId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private emitPatch(streamId: number, patch: Patch, parser: ParserLike, parseMicros: number): void {
|
|
117
|
+
this.deps.post({
|
|
118
|
+
type: "patch",
|
|
119
|
+
streamId,
|
|
120
|
+
patch,
|
|
121
|
+
appendedBytes: this.totalAppended.get(streamId) ?? 0,
|
|
122
|
+
parseMicros,
|
|
123
|
+
retainedBytes: parser.retainedBytes(),
|
|
124
|
+
wasmMemoryBytes: this.deps.memBytes(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private scheduleFlush(): void {
|
|
129
|
+
if (this.flushScheduled || !this.ready) return; // before ready, input just accumulates in `pending`
|
|
130
|
+
this.flushScheduled = true;
|
|
131
|
+
this.deps.schedule(() => this.flush());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private flush(): void {
|
|
135
|
+
this.flushScheduled = false;
|
|
136
|
+
if (!this.ready || this.pending.size === 0) return; // buffer stays put until WASM is ready
|
|
137
|
+
// Process every stream with buffered input this microtask.
|
|
138
|
+
for (const [streamId, chunk] of this.pending) {
|
|
139
|
+
this.pending.delete(streamId);
|
|
140
|
+
if (chunk.length === 0) continue;
|
|
141
|
+
const t0 = performance.now();
|
|
142
|
+
try {
|
|
143
|
+
// getOrCreate (→ makeParser) is inside the try: with `ready` gating it
|
|
144
|
+
// can't hit the init race, but any other construction failure becomes a
|
|
145
|
+
// posted error rather than an uncaught exception that kills the worker.
|
|
146
|
+
const parser = this.getOrCreate(streamId);
|
|
147
|
+
const patch = parser.append(chunk) as Patch;
|
|
148
|
+
const dt = (performance.now() - t0) * 1000;
|
|
149
|
+
this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + chunk.length);
|
|
150
|
+
this.emitPatch(streamId, patch, parser, dt);
|
|
151
|
+
} catch (e: unknown) {
|
|
152
|
+
this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Drain a stream's buffered input (if any), then finalize its parser. Shared by
|
|
158
|
+
// the `finalize` message path and markReady()'s post-ready drain.
|
|
159
|
+
private doFinalize(streamId: number): void {
|
|
160
|
+
const buffered = this.pending.get(streamId);
|
|
161
|
+
this.pending.delete(streamId);
|
|
162
|
+
try {
|
|
163
|
+
const parser = this.getOrCreate(streamId);
|
|
164
|
+
if (buffered && buffered.length > 0) {
|
|
165
|
+
parser.append(buffered);
|
|
166
|
+
this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + buffered.length);
|
|
167
|
+
}
|
|
168
|
+
const patch = parser.finalize() as Patch;
|
|
169
|
+
this.emitPatch(streamId, patch, parser, 0);
|
|
170
|
+
} catch (e: unknown) {
|
|
171
|
+
this.deps.post({ type: "error", streamId, message: e instanceof Error ? e.message : String(e) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|