brookmd 0.23.2 → 0.25.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 +104 -0
- package/README.md +83 -3
- package/dist/client.d.ts +99 -2
- package/dist/client.js +287 -26
- package/dist/dom.js +4 -1
- package/dist/html-to-react.js +29 -1
- package/dist/react.d.ts +54 -12
- package/dist/react.js +129 -20
- package/dist/server-react.js +2 -1
- package/dist/types-core.d.ts +12 -1
- package/dist/types-react.d.ts +32 -4
- package/dist/warn.d.ts +9 -0
- package/dist/warn.js +19 -0
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { warnOnce } from "./warn.js";
|
|
1
2
|
import { createWorker } from "./asset-urls.js";
|
|
2
3
|
function emptyBlockStore() {
|
|
3
4
|
return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
@@ -24,24 +25,51 @@ function applyPatch(store, patch) {
|
|
|
24
25
|
}
|
|
25
26
|
store.active = active;
|
|
26
27
|
const next = new Array(store.committedOrder.length + store.active.length);
|
|
28
|
+
let w = 0;
|
|
27
29
|
for (let i = 0; i < store.committedOrder.length; i++) {
|
|
28
|
-
|
|
30
|
+
const b = store.committed.get(store.committedOrder[i]);
|
|
31
|
+
if (b === void 0) {
|
|
32
|
+
warnOnce(
|
|
33
|
+
"orphan-committed",
|
|
34
|
+
`brookmd: committed block ${store.committedOrder[i]} is missing from the store and was dropped. This is a bug in brookmd \u2014 please report it.`
|
|
35
|
+
);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
next[w++] = b;
|
|
29
39
|
}
|
|
30
40
|
for (let i = 0; i < store.active.length; i++) {
|
|
31
|
-
|
|
41
|
+
const b = store.active[i];
|
|
42
|
+
if (b === void 0) {
|
|
43
|
+
warnOnce("orphan-active", `brookmd: active block at index ${i} is undefined and was dropped.`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
next[w++] = b;
|
|
32
47
|
}
|
|
48
|
+
if (w !== next.length) next.length = w;
|
|
33
49
|
store.snapshot = next;
|
|
34
50
|
}
|
|
35
51
|
class BrookPool {
|
|
36
|
-
constructor(factory, cap) {
|
|
52
|
+
constructor(factory, cap, options = {}) {
|
|
37
53
|
this.factory = factory;
|
|
38
54
|
this.cap = cap;
|
|
55
|
+
this.bootTimeoutMs = options.bootTimeoutMs ?? 2e4;
|
|
56
|
+
this.startTimer = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
57
|
+
this.cancelTimer = options.clearTimeout ?? ((h) => clearTimeout(h));
|
|
39
58
|
}
|
|
40
59
|
factory;
|
|
41
60
|
cap;
|
|
42
61
|
workers = [];
|
|
43
62
|
handlers = /* @__PURE__ */ new Map();
|
|
44
63
|
nextStreamId = 1;
|
|
64
|
+
// Per-worker boot deadline: if a worker reports neither ready nor a fatal
|
|
65
|
+
// failure within this window it is failed with a clear message — the miss that
|
|
66
|
+
// otherwise leaves `<div class="brook-md">` permanently empty (a stale hashed
|
|
67
|
+
// worker URL 404s after a redeploy: the DOM fires `error`, but a browser that
|
|
68
|
+
// somehow swallowed it would hang forever). `0` / non-finite disables it.
|
|
69
|
+
bootTimeoutMs;
|
|
70
|
+
// Timer machinery, injectable so the deadline is testable with fake timers.
|
|
71
|
+
startTimer;
|
|
72
|
+
cancelTimer;
|
|
45
73
|
/** Reserve a stream id and assign a worker, registering its message handler. */
|
|
46
74
|
acquire(handler) {
|
|
47
75
|
const streamId = this.nextStreamId++;
|
|
@@ -98,6 +126,7 @@ class BrookPool {
|
|
|
98
126
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
99
127
|
disposeAll() {
|
|
100
128
|
for (const pw of this.workers) {
|
|
129
|
+
this.clearBootTimer(pw);
|
|
101
130
|
try {
|
|
102
131
|
pw.worker.terminate();
|
|
103
132
|
} catch {
|
|
@@ -109,6 +138,12 @@ class BrookPool {
|
|
|
109
138
|
get workerCount() {
|
|
110
139
|
return this.workers.length;
|
|
111
140
|
}
|
|
141
|
+
/** Live stream→handler registrations. Introspection for tests/diagnostics —
|
|
142
|
+
* a fatal failure reaps the dead worker's entries, so this must not grow
|
|
143
|
+
* across a worker death + recovery cycle. */
|
|
144
|
+
get handlerCount() {
|
|
145
|
+
return this.handlers.size;
|
|
146
|
+
}
|
|
112
147
|
// Create a new worker while under cap and every live worker is busy; otherwise
|
|
113
148
|
// attach to the least-loaded LIVE worker. A fatally-failed worker is never
|
|
114
149
|
// handed out (a stream on it would post into a dead worker and hang) — it is
|
|
@@ -128,41 +163,96 @@ class BrookPool {
|
|
|
128
163
|
failed: null,
|
|
129
164
|
streamCount: 0,
|
|
130
165
|
streamIds: /* @__PURE__ */ new Set(),
|
|
131
|
-
readyWaiters: []
|
|
166
|
+
readyWaiters: [],
|
|
167
|
+
bootTimer: null
|
|
132
168
|
};
|
|
169
|
+
try {
|
|
170
|
+
pw.worker.addEventListener("error", (ev) => {
|
|
171
|
+
const detail = ev.message;
|
|
172
|
+
this.fail(pw, new Error(`brookmd worker failed to load${detail ? `: ${detail}` : ""}`));
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
pw.worker.addEventListener("messageerror", () => {
|
|
178
|
+
this.fail(pw, new Error("brookmd worker message could not be deserialized"));
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
133
182
|
pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
|
|
134
183
|
this.workers.push(pw);
|
|
184
|
+
this.startBootTimer(pw);
|
|
135
185
|
return pw;
|
|
136
186
|
}
|
|
187
|
+
// Arm the per-worker boot deadline (no-op when disabled). Uses the injected
|
|
188
|
+
// timer so tests drive it deterministically, and `.unref()`s the handle (when
|
|
189
|
+
// present) so a pending deadline never keeps a Node/bun process alive.
|
|
190
|
+
startBootTimer(pw) {
|
|
191
|
+
if (!(this.bootTimeoutMs > 0) || !Number.isFinite(this.bootTimeoutMs)) return;
|
|
192
|
+
const timer = this.startTimer(() => {
|
|
193
|
+
if (!pw.ready && !pw.failed) {
|
|
194
|
+
this.fail(pw, new Error(`brookmd worker did not become ready within ${this.bootTimeoutMs}ms`));
|
|
195
|
+
}
|
|
196
|
+
}, this.bootTimeoutMs);
|
|
197
|
+
timer?.unref?.();
|
|
198
|
+
pw.bootTimer = timer;
|
|
199
|
+
}
|
|
200
|
+
clearBootTimer(pw) {
|
|
201
|
+
if (pw.bootTimer !== null) {
|
|
202
|
+
this.cancelTimer(pw.bootTimer);
|
|
203
|
+
pw.bootTimer = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
137
206
|
onMessage(pw, msg) {
|
|
138
207
|
if (msg.type === "ready") {
|
|
139
208
|
pw.ready = true;
|
|
209
|
+
this.clearBootTimer(pw);
|
|
140
210
|
const waiters = pw.readyWaiters;
|
|
141
211
|
pw.readyWaiters = [];
|
|
142
212
|
for (const w of waiters) w.resolve();
|
|
143
213
|
return;
|
|
144
214
|
}
|
|
145
215
|
if (msg.type === "error" && msg.fatal) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
216
|
+
this.fail(pw, new Error(msg.message));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.dispatch(msg.streamId, msg);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Idempotent fatal-failure handler shared by every trigger: an in-band
|
|
223
|
+
* `{type:"error",fatal:true}` (WASM init), a DOM load `error`, a
|
|
224
|
+
* `messageerror`, and the boot deadline. First cause wins; later calls no-op.
|
|
225
|
+
*
|
|
226
|
+
* A fatally failed worker dooms every stream on it. Reject anyone awaiting
|
|
227
|
+
* readiness, then dispatch a synthetic fatal error to each live stream so its
|
|
228
|
+
* client's `onError` fires exactly as for a WASM-init fatal (the message
|
|
229
|
+
* carries no real streamId to route by). Finally evict the worker: terminate
|
|
230
|
+
* it and drop it from the pool — a dead worker can never parse again, so
|
|
231
|
+
* retaining it would leak an OS thread per failure and keep counting against
|
|
232
|
+
* `cap` until pick()'s cap branch dies and spawns workers unbounded. Reaping
|
|
233
|
+
* restores the cap and lets a fresh worker be made.
|
|
234
|
+
*/
|
|
235
|
+
fail(pw, err) {
|
|
236
|
+
if (pw.failed) return;
|
|
237
|
+
pw.failed = err;
|
|
238
|
+
this.clearBootTimer(pw);
|
|
239
|
+
const waiters = pw.readyWaiters;
|
|
240
|
+
pw.readyWaiters = [];
|
|
241
|
+
for (const w of waiters) {
|
|
157
242
|
try {
|
|
158
|
-
|
|
243
|
+
w.reject(err);
|
|
159
244
|
} catch {
|
|
160
245
|
}
|
|
161
|
-
const idx = this.workers.indexOf(pw);
|
|
162
|
-
if (idx !== -1) this.workers.splice(idx, 1);
|
|
163
|
-
return;
|
|
164
246
|
}
|
|
165
|
-
|
|
247
|
+
const msg = { type: "error", streamId: -1, message: err.message, fatal: true };
|
|
248
|
+
for (const sid of pw.streamIds) this.dispatch(sid, msg);
|
|
249
|
+
for (const sid of pw.streamIds) this.handlers.delete(sid);
|
|
250
|
+
try {
|
|
251
|
+
pw.worker.terminate();
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
const idx = this.workers.indexOf(pw);
|
|
255
|
+
if (idx !== -1) this.workers.splice(idx, 1);
|
|
166
256
|
}
|
|
167
257
|
// Route a message to a stream's handler, isolating a throwing client callback
|
|
168
258
|
// (e.g. a user-supplied onError) so it can neither break the worker message
|
|
@@ -199,6 +289,9 @@ class BrookClient {
|
|
|
199
289
|
streamId = 0;
|
|
200
290
|
config;
|
|
201
291
|
configSent = false;
|
|
292
|
+
/** Set when ensureAcquired rebound this stream onto a fresh worker+parser
|
|
293
|
+
* after a fatal failure; consumed by the next content-bearing op. */
|
|
294
|
+
pendingRebind = false;
|
|
202
295
|
listeners = /* @__PURE__ */ new Set();
|
|
203
296
|
store = emptyBlockStore();
|
|
204
297
|
onError;
|
|
@@ -209,6 +302,41 @@ class BrookClient {
|
|
|
209
302
|
// parser there, so the baseline is stale and the document must be re-fed).
|
|
210
303
|
lastContent = "";
|
|
211
304
|
contentDone = false;
|
|
305
|
+
// --- Worker-failure recovery ---
|
|
306
|
+
// The terminal fatal error for this client's stream: non-null once its worker
|
|
307
|
+
// failed AND (if a recovery was attempted) the replacement also failed. Null
|
|
308
|
+
// while a recovery is in flight / succeeded, and reset by reset(). Surfaced by
|
|
309
|
+
// the `failed` getter.
|
|
310
|
+
failedError = null;
|
|
311
|
+
// Whether auto-recovery (and the buffer that feeds it) is enabled — off by the
|
|
312
|
+
// `recovery: false` constructor option. When off, nothing is buffered and a
|
|
313
|
+
// fatal worker death is immediately terminal in BOTH modes.
|
|
314
|
+
recovery = true;
|
|
315
|
+
// The full document driven into this stream so far — accumulated in append()
|
|
316
|
+
// (so it captures BOTH manual append/pipeFrom AND setContent, which drives via
|
|
317
|
+
// append(delta)). It is the baseline re-fed after a transient worker death.
|
|
318
|
+
// resetParser() clears it and the ensuing re-feed rebuilds it, so it stays
|
|
319
|
+
// exactly equal to the live document on every path.
|
|
320
|
+
recoveryBuffer = "";
|
|
321
|
+
// Buffer length captured at the last completed re-feed. append()'s growth
|
|
322
|
+
// re-arm compares against it: once the caller drives the buffer PAST this, a
|
|
323
|
+
// future death may heal again. Set to Infinity while a recovery is pending
|
|
324
|
+
// (fatal → microtask) so a stray chunk arriving before recover() runs can't
|
|
325
|
+
// spuriously re-arm; set to the re-fed length once recover() completes. Its
|
|
326
|
+
// "!== Infinity" also stands in for "a recovery is outstanding" in the
|
|
327
|
+
// setContent divergence re-arm below.
|
|
328
|
+
recoveredLen = Infinity;
|
|
329
|
+
// One-shot guard: set when a fatal failure schedules an auto-recovery re-feed
|
|
330
|
+
// so a replacement worker that also dies is NOT retried a second time. Re-armed
|
|
331
|
+
// (cleared) only when the caller drives NEW content — never on a mere
|
|
332
|
+
// successful patch, because a finalize()-that-traps document emits an append
|
|
333
|
+
// patch before it re-traps, and re-arming there would loop the same poison doc
|
|
334
|
+
// through workers forever. Two complementary re-arm rules clear it: append()'s
|
|
335
|
+
// buffer-GROWTH check (streaming past the recovered length) and setContent()'s
|
|
336
|
+
// DIVERGENCE check (content differs from the re-fed buffer — catches a
|
|
337
|
+
// same-length-or-shorter replacement the growth check misses). Also cleared on
|
|
338
|
+
// an explicit caller reset().
|
|
339
|
+
recoveryAttempted = false;
|
|
212
340
|
// Opt-in rAF coalescing (see constructor `coalesce`). When on AND
|
|
213
341
|
// requestAnimationFrame exists, intra-frame emit()s collapse into ONE
|
|
214
342
|
// rAF-scheduled flush to listeners — the React useSyncExternalStore path then
|
|
@@ -250,6 +378,9 @@ class BrookClient {
|
|
|
250
378
|
// reads between notifies must return the SAME reference — the
|
|
251
379
|
// useSyncExternalStore cached-snapshot contract.
|
|
252
380
|
mergeCache = null;
|
|
381
|
+
/** Set by mergeStale when it had to compact a hole out of the view, so
|
|
382
|
+
* getSnapshot skips caching a view whose indices no longer track `base`. */
|
|
383
|
+
mergeDropped = false;
|
|
253
384
|
// Perf
|
|
254
385
|
appendedBytes = 0;
|
|
255
386
|
patchCount = 0;
|
|
@@ -287,6 +418,14 @@ class BrookClient {
|
|
|
287
418
|
* stream-completion (finalize) patch always flushes synchronously, and a
|
|
288
419
|
* pending frame is cancelled on `reset()`/`destroy()`. No effect when
|
|
289
420
|
* `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
|
|
421
|
+
* @param options.recovery opt-out (default `true`): transparently heal a
|
|
422
|
+
* TRANSIENT worker death. The client buffers the full driven document and, on
|
|
423
|
+
* a fatal worker failure, re-acquires a fresh worker and re-feeds it exactly
|
|
424
|
+
* once — the displayed view stays on screen, so a worker that 404s after a
|
|
425
|
+
* redeploy (or otherwise dies mid-stream) recovers invisibly instead of
|
|
426
|
+
* freezing the render. If the replacement ALSO dies the error surfaces
|
|
427
|
+
* (`failed` / `onError`). Set `false` to disable both the buffering and the
|
|
428
|
+
* auto-recovery — a fatal failure then goes straight to terminal.
|
|
290
429
|
*/
|
|
291
430
|
constructor(options = {}) {
|
|
292
431
|
this.pool = options.pool ?? getDefaultPool();
|
|
@@ -294,6 +433,7 @@ class BrookClient {
|
|
|
294
433
|
this.onError = options.onError;
|
|
295
434
|
this.onBlock = options.onBlock;
|
|
296
435
|
this.coalesce = options.coalesce ?? false;
|
|
436
|
+
this.recovery = options.recovery ?? true;
|
|
297
437
|
}
|
|
298
438
|
/**
|
|
299
439
|
* Lazily reserve this client's stream id and bind it to a pool worker. The
|
|
@@ -312,15 +452,52 @@ class BrookClient {
|
|
|
312
452
|
*/
|
|
313
453
|
ensureAcquired() {
|
|
314
454
|
if (this.pw && !this.pw.failed) return this.pw;
|
|
455
|
+
const rebinding = this.pw !== null;
|
|
315
456
|
this.pw = null;
|
|
316
457
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
317
458
|
this.streamId = streamId;
|
|
318
459
|
this.pw = pw;
|
|
460
|
+
if (rebinding) {
|
|
461
|
+
this.configSent = false;
|
|
462
|
+
this.pendingRebind = true;
|
|
463
|
+
}
|
|
319
464
|
return pw;
|
|
320
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* A worker rebind left the store holding a dead generation's blocks while the
|
|
468
|
+
* fresh parser restarts ids at 0. Called by the ops that actually feed the
|
|
469
|
+
* parser, before they do. Recovery's re-feed handles this itself (it re-parses
|
|
470
|
+
* the whole document over a preserved view) and clears the flag; this is the
|
|
471
|
+
* path where recovery is off or already spent, where the honest outcome is a
|
|
472
|
+
* clean restart rather than two generations interleaved under colliding ids.
|
|
473
|
+
*/
|
|
474
|
+
settleRebind() {
|
|
475
|
+
if (!this.pendingRebind) return;
|
|
476
|
+
if (this.failedError === null) return;
|
|
477
|
+
this.pendingRebind = false;
|
|
478
|
+
if (this.store.snapshot.length === 0 && !this.staleSnapshot) return;
|
|
479
|
+
warnOnce(
|
|
480
|
+
"rebind-reset",
|
|
481
|
+
"brookmd: the parser was rebuilt on a new worker after a fatal failure, so the document restarted. Blocks rendered before the failure were dropped because the fresh parser renumbers from zero. Enable `recovery` (the default) to re-feed and heal invisibly instead."
|
|
482
|
+
);
|
|
483
|
+
this.resetParser();
|
|
484
|
+
}
|
|
321
485
|
get ready() {
|
|
322
486
|
return this.pw?.ready ?? false;
|
|
323
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* The fatal error that killed this client's worker, or `null` if healthy.
|
|
490
|
+
*
|
|
491
|
+
* Non-null only once a failure is TERMINAL: a worker that died with recovery
|
|
492
|
+
* off or nothing buffered to re-feed, or a client (either mode) whose one-shot
|
|
493
|
+
* auto-recovery re-feed ALSO hit a dying worker. It stays `null` throughout a
|
|
494
|
+
* successful transient recovery (the death heals invisibly) and is cleared
|
|
495
|
+
* again by {@link reset}. Pairs with `onError`, which fires on the same
|
|
496
|
+
* terminal failure.
|
|
497
|
+
*/
|
|
498
|
+
get failed() {
|
|
499
|
+
return this.failedError;
|
|
500
|
+
}
|
|
324
501
|
whenReady() {
|
|
325
502
|
const pw = this.ensureAcquired();
|
|
326
503
|
return this.pool.whenWorkerReady(pw);
|
|
@@ -335,12 +512,21 @@ class BrookClient {
|
|
|
335
512
|
}
|
|
336
513
|
append(chunk) {
|
|
337
514
|
const pw = this.ensureAcquired();
|
|
515
|
+
this.settleRebind();
|
|
338
516
|
if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
|
|
517
|
+
if (this.recovery) {
|
|
518
|
+
this.recoveryBuffer += chunk;
|
|
519
|
+
if (this.recoveryAttempted && this.recoveryBuffer.length > this.recoveredLen) {
|
|
520
|
+
this.recoveryAttempted = false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
339
523
|
this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
|
|
340
524
|
}
|
|
341
525
|
finalize() {
|
|
342
526
|
const pw = this.ensureAcquired();
|
|
527
|
+
this.settleRebind();
|
|
343
528
|
this.finalizePending = true;
|
|
529
|
+
this.contentDone = true;
|
|
344
530
|
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
|
|
345
531
|
}
|
|
346
532
|
/**
|
|
@@ -431,6 +617,9 @@ class BrookClient {
|
|
|
431
617
|
* `components` instead, keeping the source append-only.
|
|
432
618
|
*/
|
|
433
619
|
setContent(content, opts) {
|
|
620
|
+
if (this.recoveryAttempted && this.recoveredLen !== Infinity && content !== this.recoveryBuffer) {
|
|
621
|
+
this.recoveryAttempted = false;
|
|
622
|
+
}
|
|
434
623
|
if (content !== this.lastContent) {
|
|
435
624
|
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
436
625
|
if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
|
|
@@ -456,6 +645,9 @@ class BrookClient {
|
|
|
456
645
|
this.staleSnapshot = null;
|
|
457
646
|
this.staleTrimmed = false;
|
|
458
647
|
this.mergeCache = null;
|
|
648
|
+
this.failedError = null;
|
|
649
|
+
this.recoveryAttempted = false;
|
|
650
|
+
this.pendingRebind = false;
|
|
459
651
|
this.resetParser();
|
|
460
652
|
if (hadContent) this.emit(true);
|
|
461
653
|
}
|
|
@@ -513,6 +705,8 @@ class BrookClient {
|
|
|
513
705
|
this.wasmMemoryBytes = 0;
|
|
514
706
|
this.lastContent = "";
|
|
515
707
|
this.contentDone = false;
|
|
708
|
+
this.recoveryBuffer = "";
|
|
709
|
+
this.recoveredLen = Infinity;
|
|
516
710
|
this.cancelFrame();
|
|
517
711
|
this.finalizePending = false;
|
|
518
712
|
this.epoch += 1;
|
|
@@ -555,7 +749,12 @@ class BrookClient {
|
|
|
555
749
|
const cache = this.mergeCache;
|
|
556
750
|
if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
|
|
557
751
|
const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
|
|
558
|
-
this.
|
|
752
|
+
if (this.mergeDropped) {
|
|
753
|
+
this.mergeDropped = false;
|
|
754
|
+
this.mergeCache = null;
|
|
755
|
+
} else {
|
|
756
|
+
this.mergeCache = { base, trimmed: this.staleTrimmed, view };
|
|
757
|
+
}
|
|
559
758
|
return view;
|
|
560
759
|
};
|
|
561
760
|
/**
|
|
@@ -586,6 +785,7 @@ class BrookClient {
|
|
|
586
785
|
const stale = this.staleSnapshot;
|
|
587
786
|
const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
|
|
588
787
|
const view = new Array(len);
|
|
788
|
+
let dropped = false;
|
|
589
789
|
for (let i = 0; i < len; i++) {
|
|
590
790
|
const nb = i < base.length ? base[i] : void 0;
|
|
591
791
|
if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
|
|
@@ -594,6 +794,10 @@ class BrookClient {
|
|
|
594
794
|
}
|
|
595
795
|
const ob = i < stale.length ? stale[i] : void 0;
|
|
596
796
|
if (!nb) {
|
|
797
|
+
if (ob === void 0) {
|
|
798
|
+
dropped = true;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
597
801
|
view[i] = ob;
|
|
598
802
|
continue;
|
|
599
803
|
}
|
|
@@ -611,6 +815,11 @@ class BrookClient {
|
|
|
611
815
|
}
|
|
612
816
|
view[i] = { ...nb, id: this.idNamespace + nb.id };
|
|
613
817
|
}
|
|
818
|
+
if (dropped) {
|
|
819
|
+
warnOnce("merge-hole", "brookmd: the stale-merge produced an empty position \u2014 compacting.");
|
|
820
|
+
this.mergeDropped = true;
|
|
821
|
+
return view.filter((b) => b !== void 0);
|
|
822
|
+
}
|
|
614
823
|
return view;
|
|
615
824
|
}
|
|
616
825
|
/**
|
|
@@ -717,15 +926,67 @@ class BrookClient {
|
|
|
717
926
|
}
|
|
718
927
|
break;
|
|
719
928
|
}
|
|
720
|
-
case "error":
|
|
721
|
-
if (
|
|
722
|
-
this.
|
|
723
|
-
|
|
724
|
-
|
|
929
|
+
case "error": {
|
|
930
|
+
if (!msg.fatal) {
|
|
931
|
+
this.reportError(msg.message, msg.fatal);
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
if (this.recovery && this.recoveryBuffer.length > 0 && !this.recoveryAttempted) {
|
|
935
|
+
this.recoveryAttempted = true;
|
|
936
|
+
this.recoveredLen = Infinity;
|
|
937
|
+
queueMicrotask(() => this.recover());
|
|
938
|
+
break;
|
|
725
939
|
}
|
|
940
|
+
this.failedError = new Error(msg.message);
|
|
941
|
+
this.reportError(msg.message, msg.fatal);
|
|
726
942
|
break;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// Surface a worker error to the caller's onError, falling back to console.
|
|
947
|
+
reportError(message, fatal) {
|
|
948
|
+
if (this.onError) {
|
|
949
|
+
this.onError({ message, fatal });
|
|
950
|
+
} else {
|
|
951
|
+
console.error("brookmd worker error:", message);
|
|
727
952
|
}
|
|
728
953
|
}
|
|
954
|
+
/**
|
|
955
|
+
* One-shot self-heal after a transient worker death, for BOTH drive modes.
|
|
956
|
+
* The dead worker was already evicted, so redriving the buffered document
|
|
957
|
+
* re-acquires a FRESH worker (ensureAcquired re-acquires because the old
|
|
958
|
+
* `pw.failed` is set). Reads the buffer at EXECUTION time, so a chunk that
|
|
959
|
+
* interleaved ahead of this microtask is included. Deliberately does NOT route
|
|
960
|
+
* through setContent(): that would stamp `lastContent`, flipping an append-mode
|
|
961
|
+
* client into setContent mode, and a second death would then re-feed a stale
|
|
962
|
+
* `lastContent` missing post-recovery chunks. `recoveryAttempted` is NOT reset
|
|
963
|
+
* here — if the replacement also dies before healing, the fatal path sees the
|
|
964
|
+
* flag still set and surfaces the error instead of looping.
|
|
965
|
+
*/
|
|
966
|
+
recover() {
|
|
967
|
+
if (!this.attached) return;
|
|
968
|
+
const doc = this.recoveryBuffer;
|
|
969
|
+
const done = this.contentDone;
|
|
970
|
+
this.refeed(doc, done);
|
|
971
|
+
this.recoveredLen = doc.length;
|
|
972
|
+
this.lastContent = doc;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Rebuild the parser onto a fresh worker and re-feed `doc` as one atomic
|
|
976
|
+
* append (re-accumulating recoveryBuffer). Keeps the displayed view on screen
|
|
977
|
+
* across the swap by softReset-ing when something is rendered, so the document
|
|
978
|
+
* never blanks; falls back to a bare resetParser when the store is empty.
|
|
979
|
+
* Uses resetParser / softReset (NOT reset(), which would clear the one-shot
|
|
980
|
+
* recovery guards mid-heal). Re-finalizes when the buffered doc was finalized.
|
|
981
|
+
*/
|
|
982
|
+
refeed(doc, done) {
|
|
983
|
+
const displayed = this.getSnapshot();
|
|
984
|
+
if (displayed.length > 0) this.softReset(displayed);
|
|
985
|
+
else this.resetParser();
|
|
986
|
+
this.pendingRebind = false;
|
|
987
|
+
this.append(doc);
|
|
988
|
+
if (done) this.finalize();
|
|
989
|
+
}
|
|
729
990
|
/**
|
|
730
991
|
* Notify subscribers of a new snapshot.
|
|
731
992
|
*
|
package/dist/dom.js
CHANGED
|
@@ -55,9 +55,11 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
55
55
|
const snapshot = client.getSnapshot();
|
|
56
56
|
const nextOrder = new Array(snapshot.length);
|
|
57
57
|
const seen = /* @__PURE__ */ new Set();
|
|
58
|
+
let w = 0;
|
|
58
59
|
for (let i = 0; i < snapshot.length; i++) {
|
|
59
60
|
const b = snapshot[i];
|
|
60
|
-
|
|
61
|
+
if (b == null || b.kind == null) continue;
|
|
62
|
+
nextOrder[w++] = b.id;
|
|
61
63
|
seen.add(b.id);
|
|
62
64
|
const existing = mounted.get(b.id);
|
|
63
65
|
if (!existing) {
|
|
@@ -131,6 +133,7 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
}
|
|
136
|
+
if (w !== nextOrder.length) nextOrder.length = w;
|
|
134
137
|
order = nextOrder;
|
|
135
138
|
reconcileChildren();
|
|
136
139
|
}
|
package/dist/html-to-react.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { createElement, Fragment } from "react";
|
|
2
2
|
import { decorateSegments } from "./decorate.js";
|
|
3
3
|
import { decodeEntities, safeUrl } from "./url-safety.js";
|
|
4
|
+
import { warnOnce } from "./warn.js";
|
|
5
|
+
const BLOCK_KIND_KEYS = /* @__PURE__ */ new Set([
|
|
6
|
+
"Paragraph",
|
|
7
|
+
"Heading",
|
|
8
|
+
"CodeBlock",
|
|
9
|
+
"MathBlock",
|
|
10
|
+
"Mermaid",
|
|
11
|
+
"List",
|
|
12
|
+
"Blockquote",
|
|
13
|
+
"Alert",
|
|
14
|
+
"Table",
|
|
15
|
+
"Rule",
|
|
16
|
+
"Html",
|
|
17
|
+
"Component"
|
|
18
|
+
]);
|
|
4
19
|
const VOID = /* @__PURE__ */ new Set([
|
|
5
20
|
"area",
|
|
6
21
|
"base",
|
|
@@ -220,6 +235,19 @@ function pushDecoratedText(out, text, decorators, ancestors, keyBase) {
|
|
|
220
235
|
out.push(createElement(Fragment, { key: keyBase + ":" + i }, replacement));
|
|
221
236
|
}
|
|
222
237
|
}
|
|
238
|
+
function resolveTagType(tag, components) {
|
|
239
|
+
const c = tag.charCodeAt(0);
|
|
240
|
+
if (c >= 65 && c <= 90 && BLOCK_KIND_KEYS.has(tag)) {
|
|
241
|
+
if (components[tag] !== void 0) {
|
|
242
|
+
warnOnce(
|
|
243
|
+
"kind-key-as-tag:" + tag,
|
|
244
|
+
`brookmd: raw <${tag}> in the rendered HTML collides with the block-kind override key "${tag}". A block-kind override receives \`BlockComponentProps\` (with \`block\`), which an inline element cannot supply, so the raw tag is rendered as a plain element instead. Rename the raw tag or the override key.`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return tag;
|
|
248
|
+
}
|
|
249
|
+
return components[tag] ?? tag;
|
|
250
|
+
}
|
|
223
251
|
function nodesToReact(nodes, components, keyPrefix, ctx, ancestors) {
|
|
224
252
|
const out = [];
|
|
225
253
|
for (let idx = 0; idx < nodes.length; idx++) {
|
|
@@ -233,7 +261,7 @@ function nodesToReact(nodes, components, keyPrefix, ctx, ancestors) {
|
|
|
233
261
|
continue;
|
|
234
262
|
}
|
|
235
263
|
const key = keyPrefix + idx;
|
|
236
|
-
const type =
|
|
264
|
+
const type = resolveTagType(n.tag, components);
|
|
237
265
|
const props = attrsToProps(n.tag, n.attrs, key, ctx?.urlTransform);
|
|
238
266
|
if (VOID.has(n.tag.toLowerCase())) {
|
|
239
267
|
out.push(createElement(type, props));
|
package/dist/react.d.ts
CHANGED
|
@@ -165,6 +165,41 @@ interface BrookMarkdownProps {
|
|
|
165
165
|
* concurrent deferral of the visible tail.
|
|
166
166
|
*/
|
|
167
167
|
deferTail?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Called when a single block's render THROWS — almost always from inside a
|
|
170
|
+
* `components` override, not from brookmd itself.
|
|
171
|
+
*
|
|
172
|
+
* Every block is wrapped in its own error boundary, so a throwing override
|
|
173
|
+
* costs you that one block instead of unmounting the whole document (React's
|
|
174
|
+
* default for an uncaught render error is to unmount the entire tree — a blank
|
|
175
|
+
* page). The failed block renders nothing; the rest of the stream keeps going,
|
|
176
|
+
* and later patches retry it.
|
|
177
|
+
*
|
|
178
|
+
* Without this hook the failure still goes to `console.error` with the block
|
|
179
|
+
* id, kind, override keys, and an HTML excerpt. Wire it to your error reporter
|
|
180
|
+
* to get the same detail in production.
|
|
181
|
+
*
|
|
182
|
+
* **HOIST / memoize this** (same trap as `components` / `onRenderMetrics`): a
|
|
183
|
+
* fresh closure each render busts every block's memo and re-renders the whole
|
|
184
|
+
* document on every patch.
|
|
185
|
+
*
|
|
186
|
+
* The most common cause by far: an override registered for a *block-kind* or
|
|
187
|
+
* *component-tag* key reading `props.block.…`, invoked on the tag path (the
|
|
188
|
+
* same tag nested inside another block, or used inline), where `block` is not
|
|
189
|
+
* supplied. Guard with `if (!block) return <>{children}</>`.
|
|
190
|
+
*/
|
|
191
|
+
onBlockError?: (error: Error, info: BlockErrorInfo) => void;
|
|
192
|
+
}
|
|
193
|
+
/** Context handed to {@link BrookMarkdownProps.onBlockError}. */
|
|
194
|
+
export interface BlockErrorInfo {
|
|
195
|
+
/** The block's stable parser-assigned id (also its React key). */
|
|
196
|
+
blockId: number;
|
|
197
|
+
/** The block kind that failed, e.g. `"Component"` / `"Table"`. */
|
|
198
|
+
kind: string;
|
|
199
|
+
/** The override keys in play — the shortlist of suspects. */
|
|
200
|
+
componentKeys: string[];
|
|
201
|
+
/** First 200 chars of the block's rendered HTML, to identify the content. */
|
|
202
|
+
html: string;
|
|
168
203
|
}
|
|
169
204
|
export declare function __resetUnstableWarnings(): void;
|
|
170
205
|
/**
|
|
@@ -183,7 +218,9 @@ export declare function __resetUnstableWarnings(): void;
|
|
|
183
218
|
*/
|
|
184
219
|
export declare function useBrookStream(stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined, options?: {
|
|
185
220
|
config?: ParserConfig;
|
|
186
|
-
onError?: (err: Error
|
|
221
|
+
onError?: (err: Error & {
|
|
222
|
+
fatal?: boolean;
|
|
223
|
+
}) => void;
|
|
187
224
|
}): BrookClient;
|
|
188
225
|
/**
|
|
189
226
|
* Own a {@link BrookClient} driven by a CONTROLLED full string — the bridge for
|
|
@@ -206,15 +243,22 @@ export declare function useBrookStream(stream: AsyncIterable<string> | ReadableS
|
|
|
206
243
|
* (reattach re-feeds the document). For a true stream source
|
|
207
244
|
* (`Response` / `ReadableStream` / SSE generator) use {@link useBrookStream}
|
|
208
245
|
* instead — it avoids buffering the whole document as a string.
|
|
246
|
+
*
|
|
247
|
+
* Pass `onError` to be notified of a terminal worker failure (`err.fatal` set) or
|
|
248
|
+
* a parse error; defaults to `console.error`. A transient worker death heals
|
|
249
|
+
* invisibly (see the client's `recovery` option) and does not fire it.
|
|
209
250
|
*/
|
|
210
251
|
export declare function useBrookMarkdownString(content: string, options?: {
|
|
211
252
|
config?: ParserConfig;
|
|
212
253
|
streaming?: boolean;
|
|
254
|
+
onError?: (err: Error & {
|
|
255
|
+
fatal?: boolean;
|
|
256
|
+
}) => void;
|
|
213
257
|
}): BrookClient;
|
|
214
258
|
declare function BrookMarkdownImpl(props: BrookMarkdownProps): import("react/jsx-runtime").JSX.Element;
|
|
215
259
|
export declare const BrookMarkdown: import("react").MemoExoticComponent<typeof BrookMarkdownImpl>;
|
|
216
260
|
export declare function blockKindProps(block: Block, components?: Components): BlockComponentProps;
|
|
217
|
-
|
|
261
|
+
interface BlockViewProps {
|
|
218
262
|
block: Block;
|
|
219
263
|
components?: Components;
|
|
220
264
|
virtualize?: boolean;
|
|
@@ -223,14 +267,12 @@ export declare function blocksEqual(prev: {
|
|
|
223
267
|
onRenderMetrics?: RenderMetricsHook;
|
|
224
268
|
decorators?: Decorator[];
|
|
225
269
|
urlTransform?: UrlTransform;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
urlTransform?: UrlTransform;
|
|
235
|
-
}): boolean;
|
|
270
|
+
/** Diagnostic passthrough for the boundary; derived from `components`, so its
|
|
271
|
+
* identity is stable whenever `components` is. */
|
|
272
|
+
componentKeys?: string[];
|
|
273
|
+
onBlockError?: (error: Error, info: BlockErrorInfo) => void;
|
|
274
|
+
}
|
|
275
|
+
export declare function blocksEqual(prev: BlockViewProps, next: BlockViewProps): boolean;
|
|
276
|
+
export declare function __getBoundaryRenders(): number;
|
|
277
|
+
export declare function __resetBoundaryRenders(): void;
|
|
236
278
|
export {};
|