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 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 WASM init fails; whenWorkerReady rejects with this thereafter. */
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
- constructor(factory: () => WorkerLike, cap: number);
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
- const err = new Error(msg.message);
147
- pw.failed = err;
148
- const waiters = pw.readyWaiters;
149
- pw.readyWaiters = [];
150
- for (const w of waiters) {
151
- try {
152
- w.reject(err);
153
- } catch {
154
- }
155
- }
156
- for (const sid of pw.streamIds) this.dispatch(sid, msg);
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
- pw.worker.terminate();
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
- this.dispatch(msg.streamId, msg);
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 (this.onError) {
722
- this.onError({ message: msg.message, fatal: msg.fatal });
723
- } else {
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) => void;
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 [client] = useState(() => new BrookClient({ config: options?.config, coalesce: true }));
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();
@@ -450,8 +450,19 @@ export type FromWorker = {
450
450
  */
451
451
  export interface WorkerLike {
452
452
  postMessage(msg: ToWorker): void;
453
- addEventListener(type: "message", listener: (ev: {
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.23.2",
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"],