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.
Files changed (90) hide show
  1. package/package.json +39 -15
  2. package/runtime/cache-sync.ts +291 -0
  3. package/runtime/cache.ts +4 -0
  4. package/runtime/cli/dev.ts +7 -0
  5. package/runtime/cli/native-routes-emit.ts +147 -1
  6. package/runtime/config.ts +42 -0
  7. package/runtime/index.d.ts +63 -0
  8. package/runtime/index.js +57 -52
  9. package/runtime/index.ts +108 -9
  10. package/runtime/native/runtime.ts +220 -7
  11. package/runtime/render/fragment.ts +87 -0
  12. package/runtime/routes.ts +225 -48
  13. package/runtime/templates.ts +47 -0
  14. package/runtime/treaty.ts +24 -1
  15. package/types/action-error.d.ts +18 -0
  16. package/types/cache-sync.d.ts +42 -0
  17. package/types/cache.d.ts +20 -0
  18. package/types/cli/help.d.ts +28 -0
  19. package/types/cli/jinja-staleness.d.ts +14 -0
  20. package/types/cli/native-routes-emit.d.ts +217 -0
  21. package/types/cli/new.d.ts +30 -0
  22. package/types/cli/templates.d.ts +39 -0
  23. package/types/client/index.d.ts +14 -0
  24. package/types/config.d.ts +42 -0
  25. package/types/cookies.d.ts +25 -0
  26. package/types/create.d.ts +1 -0
  27. package/types/css/build.d.ts +11 -0
  28. package/types/css/component-build.d.ts +17 -0
  29. package/types/css/component-loader.d.ts +8 -0
  30. package/types/css/manifest.d.ts +21 -0
  31. package/types/css/process-modules.d.ts +31 -0
  32. package/types/css/route-deps.d.ts +20 -0
  33. package/types/css/scan-imports.d.ts +13 -0
  34. package/types/css.d.ts +16 -0
  35. package/types/define-actions.d.ts +133 -0
  36. package/types/dev/client.d.ts +8 -0
  37. package/types/dev/coordinator.d.ts +33 -0
  38. package/types/dev/inject.d.ts +6 -0
  39. package/types/dev/jinja-reload.d.ts +7 -0
  40. package/types/dev/tui.d.ts +35 -0
  41. package/types/dev/watcher.d.ts +34 -0
  42. package/types/dev/worker-registry.d.ts +17 -0
  43. package/types/dev/ws-channel.d.ts +39 -0
  44. package/types/generator.d.ts +23 -0
  45. package/types/index.d.ts +222 -0
  46. package/types/islands/brust-page.d.ts +74 -0
  47. package/types/islands/build.d.ts +49 -0
  48. package/types/islands/chunk-id.d.ts +10 -0
  49. package/types/islands/importmap.d.ts +2 -0
  50. package/types/islands/island.d.ts +65 -0
  51. package/types/islands/isr-jsx.d.ts +31 -0
  52. package/types/islands/native-render.d.ts +89 -0
  53. package/types/loader-cache.d.ts +18 -0
  54. package/types/mcp/extractor.d.ts +14 -0
  55. package/types/mcp/manifest.d.ts +23 -0
  56. package/types/mcp/schema.d.ts +19 -0
  57. package/types/mcp/server.d.ts +15 -0
  58. package/types/md/emit.d.ts +72 -0
  59. package/types/md/render.d.ts +80 -0
  60. package/types/md/routes.d.ts +119 -0
  61. package/types/md/scan.d.ts +34 -0
  62. package/types/md/slug.d.ts +1 -0
  63. package/types/native/build.d.ts +30 -0
  64. package/types/native/index.d.ts +2 -0
  65. package/types/native/runtime.d.ts +52 -0
  66. package/types/navigation/active-nav.d.ts +2 -0
  67. package/types/navigation/index.d.ts +5 -0
  68. package/types/navigation/navigate.d.ts +14 -0
  69. package/types/navigation/react.d.ts +15 -0
  70. package/types/navigation/store.d.ts +44 -0
  71. package/types/render/fragment.d.ts +20 -0
  72. package/types/render/inject-action-prefix.d.ts +9 -0
  73. package/types/render/inject-css-link.d.ts +8 -0
  74. package/types/render/inject-dev-client.d.ts +6 -0
  75. package/types/render/inject-generator.d.ts +7 -0
  76. package/types/render/inject-store.d.ts +9 -0
  77. package/types/render/stream.d.ts +45 -0
  78. package/types/request-context.d.ts +16 -0
  79. package/types/routes.d.ts +506 -0
  80. package/types/sse/handler.d.ts +22 -0
  81. package/types/standard-schema.d.ts +31 -0
  82. package/types/store/define-store.d.ts +31 -0
  83. package/types/store/index.d.ts +5 -0
  84. package/types/store/react.d.ts +2 -0
  85. package/types/store/serialize.d.ts +5 -0
  86. package/types/store/server-context.d.ts +4 -0
  87. package/types/store/signal.d.ts +18 -0
  88. package/types/templates.d.ts +18 -0
  89. package/types/treaty.d.ts +70 -0
  90. 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
- observe()
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
- function observe(): void {
519
- if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
520
- if (!document.body) return // nothing to observe yet (called pre-<body>); start() re-runs on DOMContentLoaded
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(document.body, { childList: true, subtree: true })
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
+ }