brookmd 0.23.2 → 0.24.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 +37 -0
- package/README.md +12 -0
- package/dist/client.d.ts +84 -2
- package/dist/client.js +219 -23
- package/dist/react.d.ts +10 -1
- package/dist/react.js +24 -2
- package/dist/types-core.d.ts +12 -1
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ Notable changes to brookmd (formerly `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.24.0 — 2026-07-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Worker load failures are now detected and self-heal.** A worker script
|
|
12
|
+
that fails to load (e.g. a stale hashed worker URL held by an already-open
|
|
13
|
+
tab after a redeploy) fires a DOM `error` event instead of ever posting a
|
|
14
|
+
message; previously nothing listened, so the client waited forever and the
|
|
15
|
+
container stayed permanently empty with no console output. The pool now
|
|
16
|
+
listens for `error`/`messageerror`, arms a per-worker boot deadline
|
|
17
|
+
(default 20s, configurable/disable-able via the `BrookPool` options), and
|
|
18
|
+
routes every fatal trigger — WASM init failure, load error, deserialization
|
|
19
|
+
error, deadline — through one idempotent failure path: pending waiters
|
|
20
|
+
reject, each affected client's `onError` fires, and the dead worker is
|
|
21
|
+
terminated and evicted so the pool capacity recovers.
|
|
22
|
+
- **One-shot automatic recovery.** A client whose worker dies transiently
|
|
23
|
+
heals invisibly: the driven document accumulates in a recovery buffer (both
|
|
24
|
+
`setContent` and `append`/`pipeFrom` modes) and is re-fed once to a fresh
|
|
25
|
+
worker through the preserved-view swap path, so the rendered view never
|
|
26
|
+
blanks and in-flight chunks are folded in safely. Recovery re-arms only
|
|
27
|
+
when the caller advances the content, so a document that deterministically
|
|
28
|
+
crashes the parser surfaces an error after exactly one retry instead of
|
|
29
|
+
respawning workers. Opt out with `new BrookClient({ recovery: false })`
|
|
30
|
+
for memory-sensitive giant documents.
|
|
31
|
+
- **`client.failed: Error | null`** — synchronous getter for terminal worker
|
|
32
|
+
failure (stays `null` through a successful invisible heal), for rendering a
|
|
33
|
+
degraded fallback.
|
|
34
|
+
- **React hooks error surface.** `useBrookStream`'s `onError` now also
|
|
35
|
+
receives worker-level errors (with a `fatal` flag on the `Error`), and
|
|
36
|
+
`useBrookMarkdownString` gains an `onError` option; both default to
|
|
37
|
+
`console.error`.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Fatally failed workers no longer leak their per-stream handler-map entries
|
|
42
|
+
in the pool.
|
|
43
|
+
|
|
7
44
|
## 0.23.2 — 2026-07-22
|
|
8
45
|
|
|
9
46
|
### Fixed
|
package/README.md
CHANGED
|
@@ -555,7 +555,9 @@ class BrookClient {
|
|
|
555
555
|
config?: ParserConfig;
|
|
556
556
|
onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
|
|
557
557
|
onBlock?: (block: Block) => void; // fires once per block as it commits
|
|
558
|
+
recovery?: boolean; // auto-heal a transient worker death (default true)
|
|
558
559
|
});
|
|
560
|
+
get failed(): Error | null; // terminal worker failure, else null (null through heals)
|
|
559
561
|
append(chunk: string): void; // queue text for parsing
|
|
560
562
|
pipeFrom( // read → append → finalize
|
|
561
563
|
src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
|
|
@@ -591,6 +593,16 @@ failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
|
|
|
591
593
|
load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
|
|
592
594
|
effect each time a block commits (e.g. lazy-highlight a finished code block).
|
|
593
595
|
|
|
596
|
+
A **transient worker death** heals invisibly by default: if a worker dies
|
|
597
|
+
mid-stream (e.g. a stale hashed worker URL 404s after a redeploy), the client
|
|
598
|
+
buffers the driven document, re-acquires a fresh worker, and re-feeds it once —
|
|
599
|
+
the view stays on screen and `onError` does **not** fire. Only if the replacement
|
|
600
|
+
also dies is the failure terminal (`onError` with `{ fatal: true }`, and
|
|
601
|
+
`client.failed` becomes the `Error`; it is `null` while healthy and through a
|
|
602
|
+
successful heal). Set `recovery: false` to disable the buffer and auto-recovery
|
|
603
|
+
(a fatal death is then immediately terminal) — worth it for memory-sensitive,
|
|
604
|
+
very large documents where retaining the full source is undesirable.
|
|
605
|
+
|
|
594
606
|
#### Per-stream config
|
|
595
607
|
|
|
596
608
|
```ts
|
package/dist/client.d.ts
CHANGED
|
@@ -30,7 +30,9 @@ export declare function applyPatch(store: BlockStore, patch: Patch): void;
|
|
|
30
30
|
interface PoolWorker {
|
|
31
31
|
worker: WorkerLike;
|
|
32
32
|
ready: boolean;
|
|
33
|
-
/** Set once
|
|
33
|
+
/** Set once the worker fails fatally (WASM init, a DOM load `error`, a
|
|
34
|
+
* `messageerror`, or the boot deadline); whenWorkerReady rejects with this
|
|
35
|
+
* thereafter. */
|
|
34
36
|
failed: Error | null;
|
|
35
37
|
streamCount: number;
|
|
36
38
|
/** Live stream ids on this worker — so a fatal failure can notify each one. */
|
|
@@ -39,6 +41,10 @@ interface PoolWorker {
|
|
|
39
41
|
resolve: () => void;
|
|
40
42
|
reject: (e: Error) => void;
|
|
41
43
|
}>;
|
|
44
|
+
/** Handle for the boot deadline that fails a worker which never reports ready.
|
|
45
|
+
* Opaque (a `number` in the browser, a `Timeout` in Node/bun, or a test
|
|
46
|
+
* fake's id) — cleared on ready, on failure, and on pool disposal. */
|
|
47
|
+
bootTimer: unknown;
|
|
42
48
|
}
|
|
43
49
|
/**
|
|
44
50
|
* A pool of Web Workers, each multiplexing many `BrookParser`s keyed by stream
|
|
@@ -59,7 +65,14 @@ export declare class BrookPool {
|
|
|
59
65
|
private workers;
|
|
60
66
|
private handlers;
|
|
61
67
|
private nextStreamId;
|
|
62
|
-
|
|
68
|
+
private bootTimeoutMs;
|
|
69
|
+
private startTimer;
|
|
70
|
+
private cancelTimer;
|
|
71
|
+
constructor(factory: () => WorkerLike, cap: number, options?: {
|
|
72
|
+
bootTimeoutMs?: number;
|
|
73
|
+
setTimeout?: (fn: () => void, ms: number) => unknown;
|
|
74
|
+
clearTimeout?: (handle: unknown) => void;
|
|
75
|
+
});
|
|
63
76
|
/** Reserve a stream id and assign a worker, registering its message handler. */
|
|
64
77
|
acquire(handler: (msg: FromWorker) => void): {
|
|
65
78
|
streamId: number;
|
|
@@ -88,9 +101,30 @@ export declare class BrookPool {
|
|
|
88
101
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
89
102
|
disposeAll(): void;
|
|
90
103
|
get workerCount(): number;
|
|
104
|
+
/** Live stream→handler registrations. Introspection for tests/diagnostics —
|
|
105
|
+
* a fatal failure reaps the dead worker's entries, so this must not grow
|
|
106
|
+
* across a worker death + recovery cycle. */
|
|
107
|
+
get handlerCount(): number;
|
|
91
108
|
private pick;
|
|
92
109
|
private create;
|
|
110
|
+
private startBootTimer;
|
|
111
|
+
private clearBootTimer;
|
|
93
112
|
private onMessage;
|
|
113
|
+
/**
|
|
114
|
+
* Idempotent fatal-failure handler shared by every trigger: an in-band
|
|
115
|
+
* `{type:"error",fatal:true}` (WASM init), a DOM load `error`, a
|
|
116
|
+
* `messageerror`, and the boot deadline. First cause wins; later calls no-op.
|
|
117
|
+
*
|
|
118
|
+
* A fatally failed worker dooms every stream on it. Reject anyone awaiting
|
|
119
|
+
* readiness, then dispatch a synthetic fatal error to each live stream so its
|
|
120
|
+
* client's `onError` fires exactly as for a WASM-init fatal (the message
|
|
121
|
+
* carries no real streamId to route by). Finally evict the worker: terminate
|
|
122
|
+
* it and drop it from the pool — a dead worker can never parse again, so
|
|
123
|
+
* retaining it would leak an OS thread per failure and keep counting against
|
|
124
|
+
* `cap` until pick()'s cap branch dies and spawns workers unbounded. Reaping
|
|
125
|
+
* restores the cap and lets a fresh worker be made.
|
|
126
|
+
*/
|
|
127
|
+
private fail;
|
|
94
128
|
private dispatch;
|
|
95
129
|
}
|
|
96
130
|
/** The process-wide default pool every `BrookClient` shares unless given one. */
|
|
@@ -129,6 +163,11 @@ export declare class BrookClient {
|
|
|
129
163
|
private attached;
|
|
130
164
|
private lastContent;
|
|
131
165
|
private contentDone;
|
|
166
|
+
private failedError;
|
|
167
|
+
private recovery;
|
|
168
|
+
private recoveryBuffer;
|
|
169
|
+
private recoveredLen;
|
|
170
|
+
private recoveryAttempted;
|
|
132
171
|
private coalesce;
|
|
133
172
|
private rafHandle;
|
|
134
173
|
private finalizePending;
|
|
@@ -170,6 +209,14 @@ export declare class BrookClient {
|
|
|
170
209
|
* stream-completion (finalize) patch always flushes synchronously, and a
|
|
171
210
|
* pending frame is cancelled on `reset()`/`destroy()`. No effect when
|
|
172
211
|
* `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
|
|
212
|
+
* @param options.recovery opt-out (default `true`): transparently heal a
|
|
213
|
+
* TRANSIENT worker death. The client buffers the full driven document and, on
|
|
214
|
+
* a fatal worker failure, re-acquires a fresh worker and re-feeds it exactly
|
|
215
|
+
* once — the displayed view stays on screen, so a worker that 404s after a
|
|
216
|
+
* redeploy (or otherwise dies mid-stream) recovers invisibly instead of
|
|
217
|
+
* freezing the render. If the replacement ALSO dies the error surfaces
|
|
218
|
+
* (`failed` / `onError`). Set `false` to disable both the buffering and the
|
|
219
|
+
* auto-recovery — a fatal failure then goes straight to terminal.
|
|
173
220
|
*/
|
|
174
221
|
constructor(options?: {
|
|
175
222
|
pool?: BrookPool;
|
|
@@ -180,6 +227,7 @@ export declare class BrookClient {
|
|
|
180
227
|
}) => void;
|
|
181
228
|
onBlock?: (block: Block) => void;
|
|
182
229
|
coalesce?: boolean;
|
|
230
|
+
recovery?: boolean;
|
|
183
231
|
});
|
|
184
232
|
/**
|
|
185
233
|
* Lazily reserve this client's stream id and bind it to a pool worker. The
|
|
@@ -198,6 +246,17 @@ export declare class BrookClient {
|
|
|
198
246
|
*/
|
|
199
247
|
private ensureAcquired;
|
|
200
248
|
get ready(): boolean;
|
|
249
|
+
/**
|
|
250
|
+
* The fatal error that killed this client's worker, or `null` if healthy.
|
|
251
|
+
*
|
|
252
|
+
* Non-null only once a failure is TERMINAL: a worker that died with recovery
|
|
253
|
+
* off or nothing buffered to re-feed, or a client (either mode) whose one-shot
|
|
254
|
+
* auto-recovery re-feed ALSO hit a dying worker. It stays `null` throughout a
|
|
255
|
+
* successful transient recovery (the death heals invisibly) and is cleared
|
|
256
|
+
* again by {@link reset}. Pairs with `onError`, which fires on the same
|
|
257
|
+
* terminal failure.
|
|
258
|
+
*/
|
|
259
|
+
get failed(): Error | null;
|
|
201
260
|
whenReady(): Promise<void>;
|
|
202
261
|
private firstConfig;
|
|
203
262
|
append(chunk: string): void;
|
|
@@ -354,6 +413,29 @@ export declare class BrookClient {
|
|
|
354
413
|
*/
|
|
355
414
|
toPlaintext(): string;
|
|
356
415
|
private onMessage;
|
|
416
|
+
private reportError;
|
|
417
|
+
/**
|
|
418
|
+
* One-shot self-heal after a transient worker death, for BOTH drive modes.
|
|
419
|
+
* The dead worker was already evicted, so redriving the buffered document
|
|
420
|
+
* re-acquires a FRESH worker (ensureAcquired re-acquires because the old
|
|
421
|
+
* `pw.failed` is set). Reads the buffer at EXECUTION time, so a chunk that
|
|
422
|
+
* interleaved ahead of this microtask is included. Deliberately does NOT route
|
|
423
|
+
* through setContent(): that would stamp `lastContent`, flipping an append-mode
|
|
424
|
+
* client into setContent mode, and a second death would then re-feed a stale
|
|
425
|
+
* `lastContent` missing post-recovery chunks. `recoveryAttempted` is NOT reset
|
|
426
|
+
* here — if the replacement also dies before healing, the fatal path sees the
|
|
427
|
+
* flag still set and surfaces the error instead of looping.
|
|
428
|
+
*/
|
|
429
|
+
private recover;
|
|
430
|
+
/**
|
|
431
|
+
* Rebuild the parser onto a fresh worker and re-feed `doc` as one atomic
|
|
432
|
+
* append (re-accumulating recoveryBuffer). Keeps the displayed view on screen
|
|
433
|
+
* across the swap by softReset-ing when something is rendered, so the document
|
|
434
|
+
* never blanks; falls back to a bare resetParser when the store is empty.
|
|
435
|
+
* Uses resetParser / softReset (NOT reset(), which would clear the one-shot
|
|
436
|
+
* recovery guards mid-heal). Re-finalizes when the buffered doc was finalized.
|
|
437
|
+
*/
|
|
438
|
+
private refeed;
|
|
357
439
|
/**
|
|
358
440
|
* Notify subscribers of a new snapshot.
|
|
359
441
|
*
|
package/dist/client.js
CHANGED
|
@@ -33,15 +33,27 @@ function applyPatch(store, patch) {
|
|
|
33
33
|
store.snapshot = next;
|
|
34
34
|
}
|
|
35
35
|
class BrookPool {
|
|
36
|
-
constructor(factory, cap) {
|
|
36
|
+
constructor(factory, cap, options = {}) {
|
|
37
37
|
this.factory = factory;
|
|
38
38
|
this.cap = cap;
|
|
39
|
+
this.bootTimeoutMs = options.bootTimeoutMs ?? 2e4;
|
|
40
|
+
this.startTimer = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
41
|
+
this.cancelTimer = options.clearTimeout ?? ((h) => clearTimeout(h));
|
|
39
42
|
}
|
|
40
43
|
factory;
|
|
41
44
|
cap;
|
|
42
45
|
workers = [];
|
|
43
46
|
handlers = /* @__PURE__ */ new Map();
|
|
44
47
|
nextStreamId = 1;
|
|
48
|
+
// Per-worker boot deadline: if a worker reports neither ready nor a fatal
|
|
49
|
+
// failure within this window it is failed with a clear message — the miss that
|
|
50
|
+
// otherwise leaves `<div class="brook-md">` permanently empty (a stale hashed
|
|
51
|
+
// worker URL 404s after a redeploy: the DOM fires `error`, but a browser that
|
|
52
|
+
// somehow swallowed it would hang forever). `0` / non-finite disables it.
|
|
53
|
+
bootTimeoutMs;
|
|
54
|
+
// Timer machinery, injectable so the deadline is testable with fake timers.
|
|
55
|
+
startTimer;
|
|
56
|
+
cancelTimer;
|
|
45
57
|
/** Reserve a stream id and assign a worker, registering its message handler. */
|
|
46
58
|
acquire(handler) {
|
|
47
59
|
const streamId = this.nextStreamId++;
|
|
@@ -98,6 +110,7 @@ class BrookPool {
|
|
|
98
110
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
99
111
|
disposeAll() {
|
|
100
112
|
for (const pw of this.workers) {
|
|
113
|
+
this.clearBootTimer(pw);
|
|
101
114
|
try {
|
|
102
115
|
pw.worker.terminate();
|
|
103
116
|
} catch {
|
|
@@ -109,6 +122,12 @@ class BrookPool {
|
|
|
109
122
|
get workerCount() {
|
|
110
123
|
return this.workers.length;
|
|
111
124
|
}
|
|
125
|
+
/** Live stream→handler registrations. Introspection for tests/diagnostics —
|
|
126
|
+
* a fatal failure reaps the dead worker's entries, so this must not grow
|
|
127
|
+
* across a worker death + recovery cycle. */
|
|
128
|
+
get handlerCount() {
|
|
129
|
+
return this.handlers.size;
|
|
130
|
+
}
|
|
112
131
|
// Create a new worker while under cap and every live worker is busy; otherwise
|
|
113
132
|
// attach to the least-loaded LIVE worker. A fatally-failed worker is never
|
|
114
133
|
// handed out (a stream on it would post into a dead worker and hang) — it is
|
|
@@ -128,41 +147,96 @@ class BrookPool {
|
|
|
128
147
|
failed: null,
|
|
129
148
|
streamCount: 0,
|
|
130
149
|
streamIds: /* @__PURE__ */ new Set(),
|
|
131
|
-
readyWaiters: []
|
|
150
|
+
readyWaiters: [],
|
|
151
|
+
bootTimer: null
|
|
132
152
|
};
|
|
153
|
+
try {
|
|
154
|
+
pw.worker.addEventListener("error", (ev) => {
|
|
155
|
+
const detail = ev.message;
|
|
156
|
+
this.fail(pw, new Error(`brookmd worker failed to load${detail ? `: ${detail}` : ""}`));
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
pw.worker.addEventListener("messageerror", () => {
|
|
162
|
+
this.fail(pw, new Error("brookmd worker message could not be deserialized"));
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
133
166
|
pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
|
|
134
167
|
this.workers.push(pw);
|
|
168
|
+
this.startBootTimer(pw);
|
|
135
169
|
return pw;
|
|
136
170
|
}
|
|
171
|
+
// Arm the per-worker boot deadline (no-op when disabled). Uses the injected
|
|
172
|
+
// timer so tests drive it deterministically, and `.unref()`s the handle (when
|
|
173
|
+
// present) so a pending deadline never keeps a Node/bun process alive.
|
|
174
|
+
startBootTimer(pw) {
|
|
175
|
+
if (!(this.bootTimeoutMs > 0) || !Number.isFinite(this.bootTimeoutMs)) return;
|
|
176
|
+
const timer = this.startTimer(() => {
|
|
177
|
+
if (!pw.ready && !pw.failed) {
|
|
178
|
+
this.fail(pw, new Error(`brookmd worker did not become ready within ${this.bootTimeoutMs}ms`));
|
|
179
|
+
}
|
|
180
|
+
}, this.bootTimeoutMs);
|
|
181
|
+
timer?.unref?.();
|
|
182
|
+
pw.bootTimer = timer;
|
|
183
|
+
}
|
|
184
|
+
clearBootTimer(pw) {
|
|
185
|
+
if (pw.bootTimer !== null) {
|
|
186
|
+
this.cancelTimer(pw.bootTimer);
|
|
187
|
+
pw.bootTimer = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
137
190
|
onMessage(pw, msg) {
|
|
138
191
|
if (msg.type === "ready") {
|
|
139
192
|
pw.ready = true;
|
|
193
|
+
this.clearBootTimer(pw);
|
|
140
194
|
const waiters = pw.readyWaiters;
|
|
141
195
|
pw.readyWaiters = [];
|
|
142
196
|
for (const w of waiters) w.resolve();
|
|
143
197
|
return;
|
|
144
198
|
}
|
|
145
199
|
if (msg.type === "error" && msg.fatal) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
200
|
+
this.fail(pw, new Error(msg.message));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.dispatch(msg.streamId, msg);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Idempotent fatal-failure handler shared by every trigger: an in-band
|
|
207
|
+
* `{type:"error",fatal:true}` (WASM init), a DOM load `error`, a
|
|
208
|
+
* `messageerror`, and the boot deadline. First cause wins; later calls no-op.
|
|
209
|
+
*
|
|
210
|
+
* A fatally failed worker dooms every stream on it. Reject anyone awaiting
|
|
211
|
+
* readiness, then dispatch a synthetic fatal error to each live stream so its
|
|
212
|
+
* client's `onError` fires exactly as for a WASM-init fatal (the message
|
|
213
|
+
* carries no real streamId to route by). Finally evict the worker: terminate
|
|
214
|
+
* it and drop it from the pool — a dead worker can never parse again, so
|
|
215
|
+
* retaining it would leak an OS thread per failure and keep counting against
|
|
216
|
+
* `cap` until pick()'s cap branch dies and spawns workers unbounded. Reaping
|
|
217
|
+
* restores the cap and lets a fresh worker be made.
|
|
218
|
+
*/
|
|
219
|
+
fail(pw, err) {
|
|
220
|
+
if (pw.failed) return;
|
|
221
|
+
pw.failed = err;
|
|
222
|
+
this.clearBootTimer(pw);
|
|
223
|
+
const waiters = pw.readyWaiters;
|
|
224
|
+
pw.readyWaiters = [];
|
|
225
|
+
for (const w of waiters) {
|
|
157
226
|
try {
|
|
158
|
-
|
|
227
|
+
w.reject(err);
|
|
159
228
|
} catch {
|
|
160
229
|
}
|
|
161
|
-
const idx = this.workers.indexOf(pw);
|
|
162
|
-
if (idx !== -1) this.workers.splice(idx, 1);
|
|
163
|
-
return;
|
|
164
230
|
}
|
|
165
|
-
|
|
231
|
+
const msg = { type: "error", streamId: -1, message: err.message, fatal: true };
|
|
232
|
+
for (const sid of pw.streamIds) this.dispatch(sid, msg);
|
|
233
|
+
for (const sid of pw.streamIds) this.handlers.delete(sid);
|
|
234
|
+
try {
|
|
235
|
+
pw.worker.terminate();
|
|
236
|
+
} catch {
|
|
237
|
+
}
|
|
238
|
+
const idx = this.workers.indexOf(pw);
|
|
239
|
+
if (idx !== -1) this.workers.splice(idx, 1);
|
|
166
240
|
}
|
|
167
241
|
// Route a message to a stream's handler, isolating a throwing client callback
|
|
168
242
|
// (e.g. a user-supplied onError) so it can neither break the worker message
|
|
@@ -209,6 +283,41 @@ class BrookClient {
|
|
|
209
283
|
// parser there, so the baseline is stale and the document must be re-fed).
|
|
210
284
|
lastContent = "";
|
|
211
285
|
contentDone = false;
|
|
286
|
+
// --- Worker-failure recovery ---
|
|
287
|
+
// The terminal fatal error for this client's stream: non-null once its worker
|
|
288
|
+
// failed AND (if a recovery was attempted) the replacement also failed. Null
|
|
289
|
+
// while a recovery is in flight / succeeded, and reset by reset(). Surfaced by
|
|
290
|
+
// the `failed` getter.
|
|
291
|
+
failedError = null;
|
|
292
|
+
// Whether auto-recovery (and the buffer that feeds it) is enabled — off by the
|
|
293
|
+
// `recovery: false` constructor option. When off, nothing is buffered and a
|
|
294
|
+
// fatal worker death is immediately terminal in BOTH modes.
|
|
295
|
+
recovery = true;
|
|
296
|
+
// The full document driven into this stream so far — accumulated in append()
|
|
297
|
+
// (so it captures BOTH manual append/pipeFrom AND setContent, which drives via
|
|
298
|
+
// append(delta)). It is the baseline re-fed after a transient worker death.
|
|
299
|
+
// resetParser() clears it and the ensuing re-feed rebuilds it, so it stays
|
|
300
|
+
// exactly equal to the live document on every path.
|
|
301
|
+
recoveryBuffer = "";
|
|
302
|
+
// Buffer length captured at the last completed re-feed. append()'s growth
|
|
303
|
+
// re-arm compares against it: once the caller drives the buffer PAST this, a
|
|
304
|
+
// future death may heal again. Set to Infinity while a recovery is pending
|
|
305
|
+
// (fatal → microtask) so a stray chunk arriving before recover() runs can't
|
|
306
|
+
// spuriously re-arm; set to the re-fed length once recover() completes. Its
|
|
307
|
+
// "!== Infinity" also stands in for "a recovery is outstanding" in the
|
|
308
|
+
// setContent divergence re-arm below.
|
|
309
|
+
recoveredLen = Infinity;
|
|
310
|
+
// One-shot guard: set when a fatal failure schedules an auto-recovery re-feed
|
|
311
|
+
// so a replacement worker that also dies is NOT retried a second time. Re-armed
|
|
312
|
+
// (cleared) only when the caller drives NEW content — never on a mere
|
|
313
|
+
// successful patch, because a finalize()-that-traps document emits an append
|
|
314
|
+
// patch before it re-traps, and re-arming there would loop the same poison doc
|
|
315
|
+
// through workers forever. Two complementary re-arm rules clear it: append()'s
|
|
316
|
+
// buffer-GROWTH check (streaming past the recovered length) and setContent()'s
|
|
317
|
+
// DIVERGENCE check (content differs from the re-fed buffer — catches a
|
|
318
|
+
// same-length-or-shorter replacement the growth check misses). Also cleared on
|
|
319
|
+
// an explicit caller reset().
|
|
320
|
+
recoveryAttempted = false;
|
|
212
321
|
// Opt-in rAF coalescing (see constructor `coalesce`). When on AND
|
|
213
322
|
// requestAnimationFrame exists, intra-frame emit()s collapse into ONE
|
|
214
323
|
// rAF-scheduled flush to listeners — the React useSyncExternalStore path then
|
|
@@ -287,6 +396,14 @@ class BrookClient {
|
|
|
287
396
|
* stream-completion (finalize) patch always flushes synchronously, and a
|
|
288
397
|
* pending frame is cancelled on `reset()`/`destroy()`. No effect when
|
|
289
398
|
* `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
|
|
399
|
+
* @param options.recovery opt-out (default `true`): transparently heal a
|
|
400
|
+
* TRANSIENT worker death. The client buffers the full driven document and, on
|
|
401
|
+
* a fatal worker failure, re-acquires a fresh worker and re-feeds it exactly
|
|
402
|
+
* once — the displayed view stays on screen, so a worker that 404s after a
|
|
403
|
+
* redeploy (or otherwise dies mid-stream) recovers invisibly instead of
|
|
404
|
+
* freezing the render. If the replacement ALSO dies the error surfaces
|
|
405
|
+
* (`failed` / `onError`). Set `false` to disable both the buffering and the
|
|
406
|
+
* auto-recovery — a fatal failure then goes straight to terminal.
|
|
290
407
|
*/
|
|
291
408
|
constructor(options = {}) {
|
|
292
409
|
this.pool = options.pool ?? getDefaultPool();
|
|
@@ -294,6 +411,7 @@ class BrookClient {
|
|
|
294
411
|
this.onError = options.onError;
|
|
295
412
|
this.onBlock = options.onBlock;
|
|
296
413
|
this.coalesce = options.coalesce ?? false;
|
|
414
|
+
this.recovery = options.recovery ?? true;
|
|
297
415
|
}
|
|
298
416
|
/**
|
|
299
417
|
* Lazily reserve this client's stream id and bind it to a pool worker. The
|
|
@@ -321,6 +439,19 @@ class BrookClient {
|
|
|
321
439
|
get ready() {
|
|
322
440
|
return this.pw?.ready ?? false;
|
|
323
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* The fatal error that killed this client's worker, or `null` if healthy.
|
|
444
|
+
*
|
|
445
|
+
* Non-null only once a failure is TERMINAL: a worker that died with recovery
|
|
446
|
+
* off or nothing buffered to re-feed, or a client (either mode) whose one-shot
|
|
447
|
+
* auto-recovery re-feed ALSO hit a dying worker. It stays `null` throughout a
|
|
448
|
+
* successful transient recovery (the death heals invisibly) and is cleared
|
|
449
|
+
* again by {@link reset}. Pairs with `onError`, which fires on the same
|
|
450
|
+
* terminal failure.
|
|
451
|
+
*/
|
|
452
|
+
get failed() {
|
|
453
|
+
return this.failedError;
|
|
454
|
+
}
|
|
324
455
|
whenReady() {
|
|
325
456
|
const pw = this.ensureAcquired();
|
|
326
457
|
return this.pool.whenWorkerReady(pw);
|
|
@@ -336,11 +467,18 @@ class BrookClient {
|
|
|
336
467
|
append(chunk) {
|
|
337
468
|
const pw = this.ensureAcquired();
|
|
338
469
|
if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
|
|
470
|
+
if (this.recovery) {
|
|
471
|
+
this.recoveryBuffer += chunk;
|
|
472
|
+
if (this.recoveryAttempted && this.recoveryBuffer.length > this.recoveredLen) {
|
|
473
|
+
this.recoveryAttempted = false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
339
476
|
this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
|
|
340
477
|
}
|
|
341
478
|
finalize() {
|
|
342
479
|
const pw = this.ensureAcquired();
|
|
343
480
|
this.finalizePending = true;
|
|
481
|
+
this.contentDone = true;
|
|
344
482
|
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
|
|
345
483
|
}
|
|
346
484
|
/**
|
|
@@ -431,6 +569,9 @@ class BrookClient {
|
|
|
431
569
|
* `components` instead, keeping the source append-only.
|
|
432
570
|
*/
|
|
433
571
|
setContent(content, opts) {
|
|
572
|
+
if (this.recoveryAttempted && this.recoveredLen !== Infinity && content !== this.recoveryBuffer) {
|
|
573
|
+
this.recoveryAttempted = false;
|
|
574
|
+
}
|
|
434
575
|
if (content !== this.lastContent) {
|
|
435
576
|
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
436
577
|
if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
|
|
@@ -456,6 +597,8 @@ class BrookClient {
|
|
|
456
597
|
this.staleSnapshot = null;
|
|
457
598
|
this.staleTrimmed = false;
|
|
458
599
|
this.mergeCache = null;
|
|
600
|
+
this.failedError = null;
|
|
601
|
+
this.recoveryAttempted = false;
|
|
459
602
|
this.resetParser();
|
|
460
603
|
if (hadContent) this.emit(true);
|
|
461
604
|
}
|
|
@@ -513,6 +656,8 @@ class BrookClient {
|
|
|
513
656
|
this.wasmMemoryBytes = 0;
|
|
514
657
|
this.lastContent = "";
|
|
515
658
|
this.contentDone = false;
|
|
659
|
+
this.recoveryBuffer = "";
|
|
660
|
+
this.recoveredLen = Infinity;
|
|
516
661
|
this.cancelFrame();
|
|
517
662
|
this.finalizePending = false;
|
|
518
663
|
this.epoch += 1;
|
|
@@ -717,15 +862,66 @@ class BrookClient {
|
|
|
717
862
|
}
|
|
718
863
|
break;
|
|
719
864
|
}
|
|
720
|
-
case "error":
|
|
721
|
-
if (
|
|
722
|
-
this.
|
|
723
|
-
|
|
724
|
-
console.error("brookmd worker error:", msg.message);
|
|
865
|
+
case "error": {
|
|
866
|
+
if (!msg.fatal) {
|
|
867
|
+
this.reportError(msg.message, msg.fatal);
|
|
868
|
+
break;
|
|
725
869
|
}
|
|
870
|
+
if (this.recovery && this.recoveryBuffer.length > 0 && !this.recoveryAttempted) {
|
|
871
|
+
this.recoveryAttempted = true;
|
|
872
|
+
this.recoveredLen = Infinity;
|
|
873
|
+
queueMicrotask(() => this.recover());
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
this.failedError = new Error(msg.message);
|
|
877
|
+
this.reportError(msg.message, msg.fatal);
|
|
726
878
|
break;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// Surface a worker error to the caller's onError, falling back to console.
|
|
883
|
+
reportError(message, fatal) {
|
|
884
|
+
if (this.onError) {
|
|
885
|
+
this.onError({ message, fatal });
|
|
886
|
+
} else {
|
|
887
|
+
console.error("brookmd worker error:", message);
|
|
727
888
|
}
|
|
728
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* One-shot self-heal after a transient worker death, for BOTH drive modes.
|
|
892
|
+
* The dead worker was already evicted, so redriving the buffered document
|
|
893
|
+
* re-acquires a FRESH worker (ensureAcquired re-acquires because the old
|
|
894
|
+
* `pw.failed` is set). Reads the buffer at EXECUTION time, so a chunk that
|
|
895
|
+
* interleaved ahead of this microtask is included. Deliberately does NOT route
|
|
896
|
+
* through setContent(): that would stamp `lastContent`, flipping an append-mode
|
|
897
|
+
* client into setContent mode, and a second death would then re-feed a stale
|
|
898
|
+
* `lastContent` missing post-recovery chunks. `recoveryAttempted` is NOT reset
|
|
899
|
+
* here — if the replacement also dies before healing, the fatal path sees the
|
|
900
|
+
* flag still set and surfaces the error instead of looping.
|
|
901
|
+
*/
|
|
902
|
+
recover() {
|
|
903
|
+
if (!this.attached) return;
|
|
904
|
+
const doc = this.recoveryBuffer;
|
|
905
|
+
const done = this.contentDone;
|
|
906
|
+
this.refeed(doc, done);
|
|
907
|
+
this.recoveredLen = doc.length;
|
|
908
|
+
this.lastContent = doc;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Rebuild the parser onto a fresh worker and re-feed `doc` as one atomic
|
|
912
|
+
* append (re-accumulating recoveryBuffer). Keeps the displayed view on screen
|
|
913
|
+
* across the swap by softReset-ing when something is rendered, so the document
|
|
914
|
+
* never blanks; falls back to a bare resetParser when the store is empty.
|
|
915
|
+
* Uses resetParser / softReset (NOT reset(), which would clear the one-shot
|
|
916
|
+
* recovery guards mid-heal). Re-finalizes when the buffered doc was finalized.
|
|
917
|
+
*/
|
|
918
|
+
refeed(doc, done) {
|
|
919
|
+
const displayed = this.getSnapshot();
|
|
920
|
+
if (displayed.length > 0) this.softReset(displayed);
|
|
921
|
+
else this.resetParser();
|
|
922
|
+
this.append(doc);
|
|
923
|
+
if (done) this.finalize();
|
|
924
|
+
}
|
|
729
925
|
/**
|
|
730
926
|
* Notify subscribers of a new snapshot.
|
|
731
927
|
*
|
package/dist/react.d.ts
CHANGED
|
@@ -183,7 +183,9 @@ export declare function __resetUnstableWarnings(): void;
|
|
|
183
183
|
*/
|
|
184
184
|
export declare function useBrookStream(stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined, options?: {
|
|
185
185
|
config?: ParserConfig;
|
|
186
|
-
onError?: (err: Error
|
|
186
|
+
onError?: (err: Error & {
|
|
187
|
+
fatal?: boolean;
|
|
188
|
+
}) => void;
|
|
187
189
|
}): BrookClient;
|
|
188
190
|
/**
|
|
189
191
|
* Own a {@link BrookClient} driven by a CONTROLLED full string — the bridge for
|
|
@@ -206,10 +208,17 @@ export declare function useBrookStream(stream: AsyncIterable<string> | ReadableS
|
|
|
206
208
|
* (reattach re-feeds the document). For a true stream source
|
|
207
209
|
* (`Response` / `ReadableStream` / SSE generator) use {@link useBrookStream}
|
|
208
210
|
* instead — it avoids buffering the whole document as a string.
|
|
211
|
+
*
|
|
212
|
+
* Pass `onError` to be notified of a terminal worker failure (`err.fatal` set) or
|
|
213
|
+
* a parse error; defaults to `console.error`. A transient worker death heals
|
|
214
|
+
* invisibly (see the client's `recovery` option) and does not fire it.
|
|
209
215
|
*/
|
|
210
216
|
export declare function useBrookMarkdownString(content: string, options?: {
|
|
211
217
|
config?: ParserConfig;
|
|
212
218
|
streaming?: boolean;
|
|
219
|
+
onError?: (err: Error & {
|
|
220
|
+
fatal?: boolean;
|
|
221
|
+
}) => void;
|
|
213
222
|
}): BrookClient;
|
|
214
223
|
declare function BrookMarkdownImpl(props: BrookMarkdownProps): import("react/jsx-runtime").JSX.Element;
|
|
215
224
|
export declare const BrookMarkdown: import("react").MemoExoticComponent<typeof BrookMarkdownImpl>;
|
package/dist/react.js
CHANGED
|
@@ -98,9 +98,19 @@ function BrookMarkdownFromClient({
|
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
function useBrookStream(stream, options) {
|
|
101
|
-
const [client] = useState(() => new BrookClient({ config: options?.config, coalesce: true }));
|
|
102
101
|
const onErrorRef = useRef(options?.onError);
|
|
103
102
|
onErrorRef.current = options?.onError;
|
|
103
|
+
const [client] = useState(
|
|
104
|
+
() => new BrookClient({
|
|
105
|
+
config: options?.config,
|
|
106
|
+
coalesce: true,
|
|
107
|
+
onError: (err) => {
|
|
108
|
+
const e = Object.assign(new Error(err.message), { fatal: err.fatal });
|
|
109
|
+
if (onErrorRef.current) onErrorRef.current(e);
|
|
110
|
+
else console.error(e);
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
);
|
|
104
114
|
const prevStream = useRef(void 0);
|
|
105
115
|
useEffect(() => {
|
|
106
116
|
client.reattach();
|
|
@@ -123,7 +133,19 @@ function useBrookStream(stream, options) {
|
|
|
123
133
|
return client;
|
|
124
134
|
}
|
|
125
135
|
function useBrookMarkdownString(content, options) {
|
|
126
|
-
const
|
|
136
|
+
const onErrorRef = useRef(options?.onError);
|
|
137
|
+
onErrorRef.current = options?.onError;
|
|
138
|
+
const [client] = useState(
|
|
139
|
+
() => new BrookClient({
|
|
140
|
+
config: options?.config,
|
|
141
|
+
coalesce: true,
|
|
142
|
+
onError: (err) => {
|
|
143
|
+
const e = Object.assign(new Error(err.message), { fatal: err.fatal });
|
|
144
|
+
if (onErrorRef.current) onErrorRef.current(e);
|
|
145
|
+
else console.error(e);
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
);
|
|
127
149
|
useEffect(() => {
|
|
128
150
|
client.reattach();
|
|
129
151
|
return () => client.destroy();
|
package/dist/types-core.d.ts
CHANGED
|
@@ -450,8 +450,19 @@ export type FromWorker = {
|
|
|
450
450
|
*/
|
|
451
451
|
export interface WorkerLike {
|
|
452
452
|
postMessage(msg: ToWorker): void;
|
|
453
|
-
|
|
453
|
+
/**
|
|
454
|
+
* Structural superset of DOM `Worker.addEventListener` for the three channels
|
|
455
|
+
* the pool listens on: `message` (patches / ready / in-band errors — read via
|
|
456
|
+
* `ev.data`) plus the out-of-band failure channels `error` (a script that
|
|
457
|
+
* 404s or throws at load — `ev.message`) and `messageerror` (an
|
|
458
|
+
* undeserializable posted message). One widened signature keeps the unit-test
|
|
459
|
+
* fakes that declare only `"message"` compiling — method parameters are
|
|
460
|
+
* checked bivariantly — while letting the pool attach all three without a
|
|
461
|
+
* structural cast.
|
|
462
|
+
*/
|
|
463
|
+
addEventListener(type: "message" | "error" | "messageerror", listener: (ev: {
|
|
454
464
|
data: FromWorker;
|
|
465
|
+
message?: string;
|
|
455
466
|
}) => void): void;
|
|
456
467
|
terminate(): void;
|
|
457
468
|
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brookmd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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": ["./dist/worker.js", "./dist/styles.css"],
|