brustjs 0.1.50-alpha → 0.1.51-alpha
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/package.json +39 -15
- package/runtime/cache-sync.ts +291 -0
- package/runtime/cache.ts +4 -0
- package/runtime/cli/dev.ts +7 -0
- package/runtime/cli/native-routes-emit.ts +147 -1
- package/runtime/config.ts +42 -0
- package/runtime/index.d.ts +63 -0
- package/runtime/index.js +57 -52
- package/runtime/index.ts +108 -9
- package/runtime/native/runtime.ts +220 -7
- package/runtime/render/fragment.ts +87 -0
- package/runtime/routes.ts +225 -48
- package/runtime/templates.ts +47 -0
- package/runtime/treaty.ts +24 -1
- package/types/action-error.d.ts +18 -0
- package/types/cache-sync.d.ts +42 -0
- package/types/cache.d.ts +20 -0
- package/types/cli/help.d.ts +28 -0
- package/types/cli/jinja-staleness.d.ts +14 -0
- package/types/cli/native-routes-emit.d.ts +217 -0
- package/types/cli/new.d.ts +30 -0
- package/types/cli/templates.d.ts +39 -0
- package/types/client/index.d.ts +14 -0
- package/types/config.d.ts +42 -0
- package/types/cookies.d.ts +25 -0
- package/types/create.d.ts +1 -0
- package/types/css/build.d.ts +11 -0
- package/types/css/component-build.d.ts +17 -0
- package/types/css/component-loader.d.ts +8 -0
- package/types/css/manifest.d.ts +21 -0
- package/types/css/process-modules.d.ts +31 -0
- package/types/css/route-deps.d.ts +20 -0
- package/types/css/scan-imports.d.ts +13 -0
- package/types/css.d.ts +16 -0
- package/types/define-actions.d.ts +133 -0
- package/types/dev/client.d.ts +8 -0
- package/types/dev/coordinator.d.ts +33 -0
- package/types/dev/inject.d.ts +6 -0
- package/types/dev/jinja-reload.d.ts +7 -0
- package/types/dev/tui.d.ts +35 -0
- package/types/dev/watcher.d.ts +34 -0
- package/types/dev/worker-registry.d.ts +17 -0
- package/types/dev/ws-channel.d.ts +39 -0
- package/types/generator.d.ts +23 -0
- package/types/index.d.ts +222 -0
- package/types/islands/brust-page.d.ts +74 -0
- package/types/islands/build.d.ts +49 -0
- package/types/islands/chunk-id.d.ts +10 -0
- package/types/islands/importmap.d.ts +2 -0
- package/types/islands/island.d.ts +65 -0
- package/types/islands/isr-jsx.d.ts +31 -0
- package/types/islands/native-render.d.ts +89 -0
- package/types/loader-cache.d.ts +18 -0
- package/types/mcp/extractor.d.ts +14 -0
- package/types/mcp/manifest.d.ts +23 -0
- package/types/mcp/schema.d.ts +19 -0
- package/types/mcp/server.d.ts +15 -0
- package/types/md/emit.d.ts +72 -0
- package/types/md/render.d.ts +80 -0
- package/types/md/routes.d.ts +119 -0
- package/types/md/scan.d.ts +34 -0
- package/types/md/slug.d.ts +1 -0
- package/types/native/build.d.ts +30 -0
- package/types/native/index.d.ts +2 -0
- package/types/native/runtime.d.ts +52 -0
- package/types/navigation/active-nav.d.ts +2 -0
- package/types/navigation/index.d.ts +5 -0
- package/types/navigation/navigate.d.ts +14 -0
- package/types/navigation/react.d.ts +15 -0
- package/types/navigation/store.d.ts +44 -0
- package/types/render/fragment.d.ts +20 -0
- package/types/render/inject-action-prefix.d.ts +9 -0
- package/types/render/inject-css-link.d.ts +8 -0
- package/types/render/inject-dev-client.d.ts +6 -0
- package/types/render/inject-generator.d.ts +7 -0
- package/types/render/inject-store.d.ts +9 -0
- package/types/render/stream.d.ts +45 -0
- package/types/request-context.d.ts +16 -0
- package/types/routes.d.ts +506 -0
- package/types/sse/handler.d.ts +22 -0
- package/types/standard-schema.d.ts +31 -0
- package/types/store/define-store.d.ts +31 -0
- package/types/store/index.d.ts +5 -0
- package/types/store/react.d.ts +2 -0
- package/types/store/serialize.d.ts +5 -0
- package/types/store/server-context.d.ts +4 -0
- package/types/store/signal.d.ts +18 -0
- package/types/templates.d.ts +18 -0
- package/types/treaty.d.ts +70 -0
- package/types/ws/handler.d.ts +26 -0
|
@@ -50,7 +50,9 @@ export function register(name: string, behavior: Behavior): void {
|
|
|
50
50
|
/** Scan `root` (default: document) for [x-data], mount each, and (once) attach a
|
|
51
51
|
* MutationObserver for dynamic mount/dispose. Idempotent. NOTE: `root` scopes the
|
|
52
52
|
* INITIAL scan only; the observer always watches the global `document.body` (one
|
|
53
|
-
* observer per document handles every later mount/dispose, incl. SPA-nav swaps)
|
|
53
|
+
* observer per document handles every later mount/dispose, incl. SPA-nav swaps),
|
|
54
|
+
* plus one observer per discovered OPEN shadow root (a body observer cannot see
|
|
55
|
+
* mutations inside a shadow tree). */
|
|
54
56
|
export function start(root?: ParentNode): void {
|
|
55
57
|
const scope: ParentNode | undefined =
|
|
56
58
|
root ?? (typeof document !== 'undefined' ? document : undefined)
|
|
@@ -59,7 +61,7 @@ export function start(root?: ParentNode): void {
|
|
|
59
61
|
scanAndMount(scope)
|
|
60
62
|
if (!started) {
|
|
61
63
|
started = true
|
|
62
|
-
|
|
64
|
+
if (typeof document !== 'undefined' && document.body) observeRoot(document.body)
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
if (typeof document !== 'undefined' && document.readyState === 'loading') {
|
|
@@ -74,10 +76,28 @@ function scanAndMount(scope: ParentNode): void {
|
|
|
74
76
|
for (const el of Array.from(scope.querySelectorAll<HTMLElement>('[x-data]'))) {
|
|
75
77
|
mountElement(el)
|
|
76
78
|
}
|
|
79
|
+
// R10 — OPEN shadow roots host their own component trees: scan each the same
|
|
80
|
+
// way (the recursion covers roots nested within roots) and attach an observer
|
|
81
|
+
// per root, since neither the body observer nor an outer root's observer sees
|
|
82
|
+
// mutations inside an inner shadow tree. Closed roots expose `shadowRoot ===
|
|
83
|
+
// null` and are unreachable by design. The walk-all is per added subtree only.
|
|
84
|
+
if (scope instanceof HTMLElement && scope.shadowRoot) scanShadowRoot(scope.shadowRoot)
|
|
85
|
+
for (const el of Array.from(scope.querySelectorAll<HTMLElement>('*'))) {
|
|
86
|
+
if (el.shadowRoot) scanShadowRoot(el.shadowRoot)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scanShadowRoot(root: ShadowRoot): void {
|
|
91
|
+
scanAndMount(root)
|
|
92
|
+
observeRoot(root)
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
function mountElement(el: HTMLElement): void {
|
|
80
96
|
if (mounted.has(el)) return
|
|
97
|
+
// Removed mid-scan (e.g. an initial-falsy x-if subtree pruned while scanAndMount's
|
|
98
|
+
// snapshot loop was still iterating): mounting a detached element would leak its
|
|
99
|
+
// effects forever — the MutationObserver never saw it removed.
|
|
100
|
+
if (!el.isConnected) return
|
|
81
101
|
const name = el.getAttribute('x-data') ?? ''
|
|
82
102
|
const behavior = registry.get(name)
|
|
83
103
|
if (!behavior) {
|
|
@@ -154,12 +174,27 @@ function loadBehavior(name: string): void {
|
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
// Bind this element's directives, then recurse — but never descend into a nested
|
|
157
|
-
// [x-data] (it owns its own subtree and is mounted independently).
|
|
177
|
+
// [x-data] (it owns its own subtree and is mounted independently). bindTree also
|
|
178
|
+
// does NOT descend into shadow roots of elements inside an x-data subtree: a
|
|
179
|
+
// shadow root is its own composition boundary — its x-data components mount
|
|
180
|
+
// independently via scanAndMount's shadow-root scan (R10), never inheriting the
|
|
181
|
+
// enclosing instance's scope. (The `el.children` walk below naturally excludes
|
|
182
|
+
// shadow content; this is by design, not an accident.)
|
|
158
183
|
function bindTree(el: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
|
|
184
|
+
// Coexistence check MUST precede the x-for early-exit, else x-for preempts and the
|
|
185
|
+
// warn never fires. Strip x-if so x-for's template clones don't carry it either.
|
|
186
|
+
if (el.hasAttribute('x-if') && el.hasAttribute('x-for')) {
|
|
187
|
+
console.warn('[brust] x-if and x-for on the same element — x-if ignored; nest it instead')
|
|
188
|
+
el.removeAttribute('x-if')
|
|
189
|
+
}
|
|
159
190
|
if (el.hasAttribute('x-for')) {
|
|
160
191
|
bindFor(el, instance, disposers)
|
|
161
192
|
return
|
|
162
193
|
}
|
|
194
|
+
if (el.hasAttribute('x-if')) {
|
|
195
|
+
bindIf(el, instance, disposers)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
163
198
|
bindAttrs(el, instance, disposers)
|
|
164
199
|
for (const child of Array.from(el.children)) {
|
|
165
200
|
if (!(child instanceof HTMLElement)) continue
|
|
@@ -475,11 +510,162 @@ function bindForAdopt(
|
|
|
475
510
|
installKeyedReconcile(instance, parent, expr, template, anchor, map, disposers)
|
|
476
511
|
}
|
|
477
512
|
|
|
513
|
+
/** `x-if="path"` — conditional MOUNT/UNMOUNT (vs x-show's display toggle). A comment
|
|
514
|
+
* anchor marks the position; the element is captured as a template BEFORE the initial
|
|
515
|
+
* evaluation. Truthy-initial ADOPTS the original in place (no re-clone — SSR markup
|
|
516
|
+
* kept); falsy removes it. Each later falsy→truthy inserts a FRESH clone bound with
|
|
517
|
+
* fresh per-clone disposers (the installKeyedReconcile pattern); truthy→falsy removes
|
|
518
|
+
* the node and runs those disposers. The per-clone disposers cover only non-x-data
|
|
519
|
+
* teardown (bindTree skips nested x-data); nested x-data dispose/mount is delegated
|
|
520
|
+
* to the MutationObserver on removal/insert — single-owner discipline. */
|
|
521
|
+
function bindIf(el: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
|
|
522
|
+
const path = el.getAttribute('x-if') ?? ''
|
|
523
|
+
const parent = el.parentNode
|
|
524
|
+
if (!parent) return
|
|
525
|
+
const anchor = el.ownerDocument.createComment('x-if')
|
|
526
|
+
parent.insertBefore(anchor, el)
|
|
527
|
+
el.removeAttribute('x-if')
|
|
528
|
+
const template = el.cloneNode(true) as HTMLElement // capture FIRST (before initial effect)
|
|
529
|
+
let current: HTMLElement | null = el // the original, adopted if initially truthy
|
|
530
|
+
let bound = false // original starts unbound; clones are bound at creation
|
|
531
|
+
const currentDisposers: Array<() => void> = []
|
|
532
|
+
const teardown = () => {
|
|
533
|
+
for (const d of currentDisposers.splice(0)) {
|
|
534
|
+
try {
|
|
535
|
+
d()
|
|
536
|
+
} catch {
|
|
537
|
+
// keep tearing down
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (current) {
|
|
541
|
+
current.remove() // nested x-data disposal delegated to the observer's disposeTree
|
|
542
|
+
current = null
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
disposers.push(
|
|
546
|
+
effect(() => {
|
|
547
|
+
const truthy = Boolean(read(instance, path))
|
|
548
|
+
if (!truthy) {
|
|
549
|
+
teardown()
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
if (current) {
|
|
553
|
+
if (!bound) {
|
|
554
|
+
// initial truthy: adopt the original in place, no re-clone
|
|
555
|
+
bindTree(current, instance, currentDisposers)
|
|
556
|
+
bound = true
|
|
557
|
+
}
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
const clone = template.cloneNode(true) as HTMLElement
|
|
561
|
+
bindTree(clone, instance, currentDisposers) // bind BEFORE insert (observer mounts nested x-data after)
|
|
562
|
+
anchor.parentNode?.insertBefore(clone, anchor.nextSibling)
|
|
563
|
+
current = clone
|
|
564
|
+
bound = true
|
|
565
|
+
}),
|
|
566
|
+
)
|
|
567
|
+
disposers.push(teardown)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// x-model targets that already warned "not a signal" — warn once per path, then skip.
|
|
571
|
+
const warnedModelPaths = new Set<string>()
|
|
572
|
+
|
|
573
|
+
/** Write `value` into the signal at `path`, unwrapping intermediate signal/computed
|
|
574
|
+
* hops like `read()` (resolveRaw does NOT unwrap intermediates and cannot be the base
|
|
575
|
+
* for multi-hop paths). The LEAF is never called: `isSignal(leaf)` → `.set(value)`,
|
|
576
|
+
* else warn once and skip. */
|
|
577
|
+
export function writePath(scope: Instance, path: string, value: unknown): void {
|
|
578
|
+
const parts = path.split('.')
|
|
579
|
+
let cur: unknown = scope
|
|
580
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
581
|
+
if (cur == null) break
|
|
582
|
+
if (isSignal(cur) || isComputed(cur)) cur = (cur as () => unknown)()
|
|
583
|
+
cur = (cur as Record<string, unknown>)[parts[i] as string]
|
|
584
|
+
}
|
|
585
|
+
let leaf: unknown
|
|
586
|
+
if (cur != null) {
|
|
587
|
+
if (isSignal(cur) || isComputed(cur)) cur = (cur as () => unknown)()
|
|
588
|
+
leaf = (cur as Record<string, unknown>)[parts[parts.length - 1] as string]
|
|
589
|
+
}
|
|
590
|
+
if (isSignal(leaf)) {
|
|
591
|
+
;(leaf as Signal<unknown>).set(value)
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (!warnedModelPaths.has(path)) {
|
|
595
|
+
warnedModelPaths.add(path)
|
|
596
|
+
console.warn(`[brust] x-model target "${path}" is not a signal — write skipped`)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** `x-model="path"` — two-way binding for form controls. Write side: checkbox/radio
|
|
601
|
+
* on 'change' (checkbox → boolean checked; radio → its value when checked), everything
|
|
602
|
+
* else (text/textarea/select-single/other inputs) → string value on 'input'. Read side:
|
|
603
|
+
* one reflect effect per element with an echo guard (`el.value !== v`/`el.checked !== v`)
|
|
604
|
+
* so a reflected write never loops (signal.set with an equal value is a no-op anyway).
|
|
605
|
+
* `select[multiple]` is rejected at bind time with one warn — no listener. */
|
|
606
|
+
function bindModel(
|
|
607
|
+
el: HTMLElement,
|
|
608
|
+
scope: Instance,
|
|
609
|
+
path: string,
|
|
610
|
+
disposers: Array<() => void>,
|
|
611
|
+
): void {
|
|
612
|
+
if (el.tagName === 'SELECT' && (el as HTMLSelectElement).multiple) {
|
|
613
|
+
console.warn('[brust] x-model on select[multiple] is not supported — binding skipped')
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
const input = el as HTMLInputElement
|
|
617
|
+
const type = el.tagName === 'INPUT' ? (input.type ?? '').toLowerCase() : ''
|
|
618
|
+
if (type === 'checkbox') {
|
|
619
|
+
const onChange = () => writePath(scope, path, input.checked)
|
|
620
|
+
el.addEventListener('change', onChange)
|
|
621
|
+
disposers.push(() => el.removeEventListener('change', onChange))
|
|
622
|
+
disposers.push(
|
|
623
|
+
effect(() => {
|
|
624
|
+
const v = Boolean(read(scope, path))
|
|
625
|
+
if (input.checked !== v) input.checked = v
|
|
626
|
+
}),
|
|
627
|
+
)
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
if (type === 'radio') {
|
|
631
|
+
const onChange = () => {
|
|
632
|
+
if (input.checked) writePath(scope, path, input.value)
|
|
633
|
+
}
|
|
634
|
+
el.addEventListener('change', onChange)
|
|
635
|
+
disposers.push(() => el.removeEventListener('change', onChange))
|
|
636
|
+
// Per-radio reflect: checked = (signalValue === el.value) — group consistency
|
|
637
|
+
// falls out naturally, no special-casing.
|
|
638
|
+
disposers.push(
|
|
639
|
+
effect(() => {
|
|
640
|
+
const on = read(scope, path) === input.value
|
|
641
|
+
if (input.checked !== on) input.checked = on
|
|
642
|
+
}),
|
|
643
|
+
)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
// text/textarea/select(single)/other inputs → string value on 'input'
|
|
647
|
+
const valueEl = el as unknown as { value: string }
|
|
648
|
+
const onInput = () => writePath(scope, path, valueEl.value)
|
|
649
|
+
el.addEventListener('input', onInput)
|
|
650
|
+
disposers.push(() => el.removeEventListener('input', onInput))
|
|
651
|
+
disposers.push(
|
|
652
|
+
effect(() => {
|
|
653
|
+
const v = read(scope, path)
|
|
654
|
+
const s = v == null ? '' : String(v)
|
|
655
|
+
if (valueEl.value !== s) valueEl.value = s
|
|
656
|
+
}),
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
|
|
478
660
|
function bindAttrs(el: HTMLElement, scope: Instance, disposers: Array<() => void>): void {
|
|
479
661
|
for (const attr of Array.from(el.attributes)) {
|
|
480
662
|
const name = attr.name
|
|
481
663
|
const value = attr.value
|
|
482
664
|
if (name === 'x-data' || name === 'x-props') continue
|
|
665
|
+
if (name === 'x-model') {
|
|
666
|
+
bindModel(el, scope, value, disposers)
|
|
667
|
+
continue
|
|
668
|
+
}
|
|
483
669
|
if (name === 'x-text') {
|
|
484
670
|
disposers.push(
|
|
485
671
|
effect(() => {
|
|
@@ -515,9 +701,20 @@ function bindAttrs(el: HTMLElement, scope: Instance, disposers: Array<() => void
|
|
|
515
701
|
}
|
|
516
702
|
}
|
|
517
703
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
704
|
+
// Roots that already have a mount/dispose observer attached (document.body +
|
|
705
|
+
// every discovered open shadow root). A WeakSet so a removed shadow root stays
|
|
706
|
+
// GC-collectable — its observer dies with it.
|
|
707
|
+
const observedRoots = new WeakSet<Node>()
|
|
708
|
+
|
|
709
|
+
/** Attach the mount/dispose MutationObserver to `root` (document.body or an
|
|
710
|
+
* open ShadowRoot), once per root. Every observer runs the same callback:
|
|
711
|
+
* dispose removed subtrees, scan added ones — and `scanAndMount`'s recursion
|
|
712
|
+
* means a subtree added INSIDE a shadow root that itself hosts more shadow
|
|
713
|
+
* roots gets those scanned and observed too. */
|
|
714
|
+
function observeRoot(root: Node): void {
|
|
715
|
+
if (typeof MutationObserver === 'undefined') return
|
|
716
|
+
if (observedRoots.has(root)) return
|
|
717
|
+
observedRoots.add(root)
|
|
521
718
|
const obs = new MutationObserver((records) => {
|
|
522
719
|
for (const rec of records) {
|
|
523
720
|
for (const node of Array.from(rec.removedNodes)) {
|
|
@@ -528,7 +725,7 @@ function observe(): void {
|
|
|
528
725
|
}
|
|
529
726
|
}
|
|
530
727
|
})
|
|
531
|
-
obs.observe(
|
|
728
|
+
obs.observe(root, { childList: true, subtree: true })
|
|
532
729
|
}
|
|
533
730
|
|
|
534
731
|
function disposeTree(node: HTMLElement): void {
|
|
@@ -536,6 +733,22 @@ function disposeTree(node: HTMLElement): void {
|
|
|
536
733
|
for (const el of Array.from(node.querySelectorAll<HTMLElement>('[x-data]'))) {
|
|
537
734
|
disposeElement(el)
|
|
538
735
|
}
|
|
736
|
+
// R10 — a removed HOST's shadow contents never reach any observer: the host's
|
|
737
|
+
// removal fires on the light tree's observer, and the shadow root's own
|
|
738
|
+
// observer only sees mutations INSIDE the root. Walk shadow roots explicitly.
|
|
739
|
+
if (node.shadowRoot) disposeShadowContents(node.shadowRoot)
|
|
740
|
+
for (const el of Array.from(node.querySelectorAll<HTMLElement>('*'))) {
|
|
741
|
+
if (el.shadowRoot) disposeShadowContents(el.shadowRoot)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function disposeShadowContents(root: ShadowRoot): void {
|
|
746
|
+
for (const el of Array.from(root.querySelectorAll<HTMLElement>('[x-data]'))) {
|
|
747
|
+
disposeElement(el)
|
|
748
|
+
}
|
|
749
|
+
for (const el of Array.from(root.querySelectorAll<HTMLElement>('*'))) {
|
|
750
|
+
if (el.shadowRoot) disposeShadowContents(el.shadowRoot)
|
|
751
|
+
}
|
|
539
752
|
}
|
|
540
753
|
|
|
541
754
|
function disposeElement(el: HTMLElement): void {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Fragment rendering — render a React tree to a single awaited HTML string.
|
|
2
|
+
//
|
|
3
|
+
// Two consumers:
|
|
4
|
+
// - routes.ts navigationBranch (SPA-nav payloads) — imports renderToAwaitedString.
|
|
5
|
+
// - the public `renderFragment` API (R4) — render a component to static HTML
|
|
6
|
+
// with the framework's request/store/loader-cache contexts scoped per call.
|
|
7
|
+
//
|
|
8
|
+
// React 19 split react-dom/server by runtime: under Bun the bare
|
|
9
|
+
// 'react-dom/server' resolves to the web-streams build (server.bun.js), which
|
|
10
|
+
// only exports renderToReadableStream. renderToPipeableStream (Node streams —
|
|
11
|
+
// what our Writable sink + onAllReady/onShellReady path uses) lives in the
|
|
12
|
+
// .node build. Import it explicitly so SSR works on the react@19 we declare as
|
|
13
|
+
// a peer (React 18 shipped renderToPipeableStream in the bun build, which is
|
|
14
|
+
// why this was silently fine).
|
|
15
|
+
import { createElement, type ComponentType, type ReactNode } from 'react'
|
|
16
|
+
import { renderToPipeableStream } from 'react-dom/server.node'
|
|
17
|
+
import { Writable } from 'node:stream'
|
|
18
|
+
import { Buffer } from 'node:buffer'
|
|
19
|
+
import { runInRequestScope } from '../request-context.ts'
|
|
20
|
+
import { runInRequestCache } from '../loader-cache.ts'
|
|
21
|
+
import { runInStoreContext } from '../store/server-context.ts'
|
|
22
|
+
|
|
23
|
+
/** Render a React element to a single HTML string, awaiting all Suspense
|
|
24
|
+
* boundaries via onAllReady. Used by navigationBranch — renderToString
|
|
25
|
+
* would only capture the shell + fallbacks, while renderBranchStreaming
|
|
26
|
+
* is for the streaming render path. */
|
|
27
|
+
export function renderToAwaitedString(element: ReactNode): Promise<string> {
|
|
28
|
+
return new Promise<string>((resolve, reject) => {
|
|
29
|
+
const chunks: Buffer[] = []
|
|
30
|
+
const sink = new Writable({
|
|
31
|
+
write(chunk, _enc, cb) {
|
|
32
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
33
|
+
cb()
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
sink.on('finish', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
37
|
+
sink.on('error', reject)
|
|
38
|
+
|
|
39
|
+
let stream: ReturnType<typeof renderToPipeableStream>
|
|
40
|
+
stream = renderToPipeableStream(element, {
|
|
41
|
+
onAllReady() {
|
|
42
|
+
try {
|
|
43
|
+
stream.pipe(sink)
|
|
44
|
+
} catch (e) {
|
|
45
|
+
reject(e)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
onShellError(err) {
|
|
49
|
+
reject(err)
|
|
50
|
+
},
|
|
51
|
+
onError(err) {
|
|
52
|
+
// Logged at the navigationBranch level; let onAllReady drive completion.
|
|
53
|
+
console.error('[brust] render onError (navigation/fragment):', err)
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RenderFragmentOpts {
|
|
60
|
+
/** Request cookies visible to cookies()/session helpers inside the tree. */
|
|
61
|
+
cookies?: Record<string, string>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Render a component to an HTML string with framework contexts scoped:
|
|
65
|
+
* request scope (cookies) ∘ request cache (cachedFetch/dedupe) ∘ store
|
|
66
|
+
* context (fresh per call — no cross-call leakage; concurrency-safe via
|
|
67
|
+
* AsyncLocalStorage). Suspense supported — the promise resolves when the
|
|
68
|
+
* whole tree is ready, never with fallbacks. Static HTML only: no island
|
|
69
|
+
* hydration markers/scripts (an `<Island>` inside the fragment SSRs its
|
|
70
|
+
* component but ships no hydration), no head/CSS collection, no streaming.
|
|
71
|
+
* Native/jinja fragments are served by `templates.render` instead.
|
|
72
|
+
* Errors reject with the component's error — no error-boundary semantics. */
|
|
73
|
+
export async function renderFragment<P extends object>(
|
|
74
|
+
Component: ComponentType<P>,
|
|
75
|
+
props: P,
|
|
76
|
+
opts?: RenderFragmentOpts,
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
// Composition mirrors routes.ts runInRequestContext: scope outermost, then
|
|
79
|
+
// the request-scoped loader cache, then the per-call store context.
|
|
80
|
+
return runInRequestScope(opts?.cookies ?? {}, () =>
|
|
81
|
+
runInRequestCache(() =>
|
|
82
|
+
runInStoreContext(() =>
|
|
83
|
+
renderToAwaitedString(createElement(Component as ComponentType, props as object)),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
}
|