brustjs 0.1.63-alpha → 0.1.65-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/README.md CHANGED
@@ -114,6 +114,11 @@ Click either one — both update. [See it live](https://brust.assetsart.com/) ·
114
114
  **resources** at `/_brust/mcp`; `tools/call` runs through the same validation
115
115
  and middleware as HTTP, so agents drive the app without scraping.
116
116
  → [Agents](https://brust.assetsart.com/docs/agents)
117
+ - **Browser-native AI runtime** — `window.Brust` lets an agent inspect routes
118
+ and the rendered page, act through real DOM events, and navigate with
119
+ framework-aware settling. It is automatic in dev and opt-in for production,
120
+ with no client cost while disabled.
121
+ → [Agents](https://brust.assetsart.com/docs/agents#driving-the-ui--windowbrust)
117
122
  - Plus: nested routes + dynamic params, typed loaders, request-scoped
118
123
  middleware, SPA-style navigation, SSE & WebSockets as first-class route
119
124
  shapes, response + ISR caches, Tailwind v4 + CSS Modules, static
@@ -155,6 +160,7 @@ brustjs build <entry> --out-dir D # prebuilt ./dist/ — run from the project (b
155
160
  --target <auto|all|TARGET[,…]> # which native binary to bundle (default: auto = host platform)
156
161
  --ssg [--ssg-out D] # prerender static routes (incl. markdown pages) to HTML
157
162
  # + per-route SPA payloads — client-side nav works statically
163
+ --ai # include window.Brust + its route manifest in production
158
164
  brustjs new <name> # scaffold a project (partial — see Status)
159
165
  ```
160
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.63-alpha",
3
+ "version": "0.1.65-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,12 +41,12 @@
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "optionalDependencies": {
44
- "brustjs-darwin-x64": "0.1.63-alpha",
45
- "brustjs-darwin-arm64": "0.1.63-alpha",
46
- "brustjs-linux-x64-gnu": "0.1.63-alpha",
47
- "brustjs-linux-arm64-gnu": "0.1.63-alpha",
48
- "brustjs-linux-x64-musl": "0.1.63-alpha",
49
- "brustjs-linux-arm64-musl": "0.1.63-alpha"
44
+ "brustjs-darwin-x64": "0.1.65-alpha",
45
+ "brustjs-darwin-arm64": "0.1.65-alpha",
46
+ "brustjs-linux-x64-gnu": "0.1.65-alpha",
47
+ "brustjs-linux-arm64-gnu": "0.1.65-alpha",
48
+ "brustjs-linux-x64-musl": "0.1.65-alpha",
49
+ "brustjs-linux-arm64-musl": "0.1.65-alpha"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^19.2.6",
@@ -0,0 +1,271 @@
1
+ import { settleAfterAction } from './navigate.ts'
2
+ import { resolveTarget, type ActionTarget, type ErrorResult } from './refs.ts'
3
+
4
+ export interface CapturedError {
5
+ message: string
6
+ stack?: string
7
+ }
8
+
9
+ export interface ActionResult {
10
+ ok: true
11
+ navigated: boolean
12
+ url: string
13
+ errors: CapturedError[]
14
+ }
15
+
16
+ type ActionResponse = ActionResult | ErrorResult
17
+ type Fillable = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLElement
18
+
19
+ const activeCaptures = new Set<CapturedError[]>()
20
+ let consoleWrapped = false
21
+ let listenerWindow: Window | null = null
22
+
23
+ function captured(value: unknown): CapturedError {
24
+ if (value instanceof Error)
25
+ return { message: value.message, ...(value.stack ? { stack: value.stack } : {}) }
26
+ return { message: typeof value === 'string' ? value : String(value) }
27
+ }
28
+
29
+ function record(value: unknown): void {
30
+ const entry = captured(value)
31
+ for (const errors of activeCaptures) errors.push(entry)
32
+ }
33
+
34
+ function beginCapture(): CapturedError[] {
35
+ if (!consoleWrapped) {
36
+ consoleWrapped = true
37
+ const original = console.error.bind(console)
38
+ console.error = (...args: unknown[]) => {
39
+ record(args.length === 1 ? args[0] : args.map((arg) => captured(arg).message).join(' '))
40
+ original(...args)
41
+ }
42
+ }
43
+ if (listenerWindow !== window) {
44
+ listenerWindow = window
45
+ window.addEventListener('error', (event) => record(event.error ?? event.message))
46
+ window.addEventListener('unhandledrejection', (event) => record(event.reason))
47
+ }
48
+ const errors: CapturedError[] = []
49
+ activeCaptures.add(errors)
50
+ return errors
51
+ }
52
+
53
+ function badInput(message: string, hint?: string): ErrorResult {
54
+ return { ok: false, error: { code: 'bad-input', message, ...(hint ? { hint } : {}) } }
55
+ }
56
+
57
+ function prepare(target: ActionTarget): Element | ErrorResult {
58
+ const element = resolveTarget(target)
59
+ if ('ok' in element) return element
60
+ element.scrollIntoView?.({ block: 'center', inline: 'center' })
61
+ return element
62
+ }
63
+
64
+ async function result(beforeUrl: string, errors: CapturedError[]): Promise<ActionResponse> {
65
+ const settleError = await settleAfterAction()
66
+ activeCaptures.delete(errors)
67
+ if (settleError) return settleError
68
+ return { ok: true, navigated: location.href !== beforeUrl, url: location.href, errors }
69
+ }
70
+
71
+ function event(name: string, EventType: typeof Event = Event): Event {
72
+ return new EventType(name, { bubbles: true, cancelable: true })
73
+ }
74
+
75
+ export async function click(target: ActionTarget): Promise<ActionResponse> {
76
+ const element = prepare(target)
77
+ if ('ok' in element) return element
78
+ const before = location.href
79
+ const errors = beginCapture()
80
+ const Pointer = globalThis.PointerEvent ?? MouseEvent
81
+ element.dispatchEvent(event('pointerdown', Pointer))
82
+ element.dispatchEvent(event('pointerup', Pointer))
83
+ element.dispatchEvent(event('click', MouseEvent))
84
+ return result(before, errors)
85
+ }
86
+
87
+ export async function focus(target: ActionTarget): Promise<ActionResponse> {
88
+ const element = prepare(target)
89
+ if ('ok' in element) return element
90
+ const before = location.href
91
+ if (!(element instanceof HTMLElement)) return badInput('focus target is not an HTMLElement')
92
+ const errors = beginCapture()
93
+ element.focus()
94
+ return result(before, errors)
95
+ }
96
+
97
+ export async function blur(target: ActionTarget): Promise<ActionResponse> {
98
+ const element = prepare(target)
99
+ if ('ok' in element) return element
100
+ const before = location.href
101
+ if (!(element instanceof HTMLElement)) return badInput('blur target is not an HTMLElement')
102
+ const errors = beginCapture()
103
+ element.blur()
104
+ return result(before, errors)
105
+ }
106
+
107
+ function setNativeValue(element: Fillable, value: string): ErrorResult | null {
108
+ if (element.isContentEditable) {
109
+ element.textContent = value
110
+ return null
111
+ }
112
+ const prototype =
113
+ element instanceof HTMLInputElement
114
+ ? HTMLInputElement.prototype
115
+ : element instanceof HTMLTextAreaElement
116
+ ? HTMLTextAreaElement.prototype
117
+ : element instanceof HTMLSelectElement
118
+ ? HTMLSelectElement.prototype
119
+ : null
120
+ const setter = prototype && Object.getOwnPropertyDescriptor(prototype, 'value')?.set
121
+ if (!setter) return badInput('fill target is not an input, textarea, select, or contenteditable')
122
+ setter.call(element, value)
123
+ return null
124
+ }
125
+
126
+ async function fillElement(element: Element, value: string): Promise<ErrorResult | null> {
127
+ if (!(element instanceof HTMLElement)) return badInput('fill target is not an HTMLElement')
128
+ const error = setNativeValue(element, value)
129
+ if (error) return error
130
+ element.dispatchEvent(event('input', InputEvent))
131
+ element.dispatchEvent(event('change'))
132
+ return null
133
+ }
134
+
135
+ export async function fill(target: ActionTarget, value: unknown): Promise<ActionResponse> {
136
+ const element = prepare(target)
137
+ if ('ok' in element) return element
138
+ if (typeof value !== 'string' && typeof value !== 'number') {
139
+ return badInput('fill value must be a string or number')
140
+ }
141
+ const before = location.href
142
+ const errors = beginCapture()
143
+ const error = await fillElement(element, String(value))
144
+ if (error) {
145
+ activeCaptures.delete(errors)
146
+ return error
147
+ }
148
+ return result(before, errors)
149
+ }
150
+
151
+ function findForm(nameOrRef: string): HTMLFormElement | ErrorResult {
152
+ if (/^e\d+$/.test(nameOrRef)) {
153
+ const resolved = resolveTarget(nameOrRef)
154
+ if ('ok' in resolved) return resolved
155
+ return resolved instanceof HTMLFormElement ? resolved : badInput(`${nameOrRef} is not a form`)
156
+ }
157
+ const matches = Array.from(document.forms).filter(
158
+ (candidate) =>
159
+ candidate.getAttribute('data-ai-name') === nameOrRef ||
160
+ candidate.name === nameOrRef ||
161
+ candidate.id === nameOrRef,
162
+ )
163
+ if (matches.length === 0) {
164
+ const resolved = resolveTarget(nameOrRef)
165
+ if ('ok' in resolved) return resolved
166
+ return resolved instanceof HTMLFormElement
167
+ ? resolved
168
+ : badInput(`${nameOrRef} does not resolve to a form`)
169
+ }
170
+ if (matches.length > 1) {
171
+ return {
172
+ ok: false,
173
+ error: { code: 'ambiguous', message: `multiple forms named: ${nameOrRef}` },
174
+ }
175
+ }
176
+ return matches[0]!
177
+ }
178
+
179
+ export async function form(
180
+ nameOrRef: string,
181
+ values: Record<string, unknown>,
182
+ options: { submit?: boolean } = {},
183
+ ): Promise<ActionResponse> {
184
+ const formElement = findForm(nameOrRef)
185
+ if ('ok' in formElement) return formElement
186
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
187
+ return badInput('form values must be an object')
188
+ }
189
+ const fields = Array.from(formElement.elements).filter(
190
+ (element): element is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement =>
191
+ element instanceof HTMLInputElement ||
192
+ element instanceof HTMLTextAreaElement ||
193
+ element instanceof HTMLSelectElement,
194
+ )
195
+ const errors = beginCapture()
196
+ const available = [...new Set(fields.map((field) => field.name).filter(Boolean))]
197
+ for (const [name, value] of Object.entries(values)) {
198
+ const matching = fields.filter((field) => field.name === name)
199
+ if (matching.length === 0) {
200
+ activeCaptures.delete(errors)
201
+ return badInput(`unknown form field: ${name}`, `available fields: ${available.join(', ')}`)
202
+ }
203
+ const field = matching[0]!
204
+ if (
205
+ field instanceof HTMLInputElement &&
206
+ (field.type === 'checkbox' || field.type === 'radio')
207
+ ) {
208
+ const checked = typeof value === 'boolean' ? value : String(value) === field.value
209
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked')
210
+ descriptor?.set?.call(field, checked)
211
+ field.dispatchEvent(event('input', InputEvent))
212
+ field.dispatchEvent(event('change'))
213
+ } else {
214
+ const error = await fillElement(field, String(value ?? ''))
215
+ if (error) {
216
+ activeCaptures.delete(errors)
217
+ return error
218
+ }
219
+ }
220
+ }
221
+ const before = location.href
222
+ if (options.submit !== false) formElement.requestSubmit()
223
+ return result(before, errors)
224
+ }
225
+
226
+ export async function press(target: ActionTarget, key: string): Promise<ActionResponse> {
227
+ const element = prepare(target)
228
+ if ('ok' in element) return element
229
+ if (!key) return badInput('key must be a non-empty string')
230
+ const before = location.href
231
+ const errors = beginCapture()
232
+ for (const type of ['keydown', 'keypress', 'keyup']) {
233
+ element.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true }))
234
+ }
235
+ return result(before, errors)
236
+ }
237
+
238
+ export async function select(target: ActionTarget, valueOrLabel: string): Promise<ActionResponse> {
239
+ const element = prepare(target)
240
+ if ('ok' in element) return element
241
+ if (!(element instanceof HTMLSelectElement)) return badInput('select target is not a select')
242
+ const option = Array.from(element.options).find(
243
+ (candidate) => candidate.value === valueOrLabel || candidate.text === valueOrLabel,
244
+ )
245
+ if (!option) return badInput(`select option not found: ${valueOrLabel}`)
246
+ const before = location.href
247
+ const errors = beginCapture()
248
+ const error = setNativeValue(element, option.value)
249
+ if (error) {
250
+ activeCaptures.delete(errors)
251
+ return error
252
+ }
253
+ element.dispatchEvent(event('input', InputEvent))
254
+ element.dispatchEvent(event('change'))
255
+ return result(before, errors)
256
+ }
257
+
258
+ export async function check(target: ActionTarget, checked: boolean): Promise<ActionResponse> {
259
+ const element = prepare(target)
260
+ if ('ok' in element) return element
261
+ if (!(element instanceof HTMLInputElement) || !['checkbox', 'radio'].includes(element.type)) {
262
+ return badInput('check target is not a checkbox or radio input')
263
+ }
264
+ const before = location.href
265
+ const errors = beginCapture()
266
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked')?.set
267
+ setter?.call(element, checked)
268
+ element.dispatchEvent(event('input', InputEvent))
269
+ element.dispatchEvent(event('change'))
270
+ return result(before, errors)
271
+ }
@@ -0,0 +1,90 @@
1
+ /** Browser-only AI agent runtime. This standalone entry deliberately imports
2
+ * neither runtime/index.ts nor runtime/routes.ts: both pull server/React code
3
+ * into a browser bundle. Keep this module family React-free.
4
+ */
5
+ import packageJson from '../../package.json' with { type: 'json' }
6
+ import * as actions from './actions.ts'
7
+ import { back, navigate, reload } from './navigate.ts'
8
+ import { pages } from './pages.ts'
9
+ import { struct } from './struct.ts'
10
+ import type { BrustError, ErrorResult } from './refs.ts'
11
+
12
+ type PublicMethod = (...args: never[]) => unknown
13
+
14
+ function envelope<T extends PublicMethod>(method: T): (...args: Parameters<T>) => Promise<unknown> {
15
+ return async (...args: Parameters<T>) => {
16
+ try {
17
+ return await method(...args)
18
+ } catch (cause) {
19
+ const message = cause instanceof Error ? cause.message : String(cause)
20
+ return {
21
+ ok: false,
22
+ error: { code: 'bad-input', message },
23
+ } satisfies ErrorResult
24
+ }
25
+ }
26
+ }
27
+
28
+ function disabled(): Promise<ErrorResult> {
29
+ return Promise.resolve({ ok: false, error: { code: 'disabled', message: 'P2' } })
30
+ }
31
+
32
+ export interface BrustRuntime {
33
+ version: { api: 1; brust: string }
34
+ pages: ReturnType<typeof envelope<typeof pages>>
35
+ struct: ReturnType<typeof envelope<typeof struct>>
36
+ action: {
37
+ click: ReturnType<typeof envelope<typeof actions.click>>
38
+ focus: ReturnType<typeof envelope<typeof actions.focus>>
39
+ blur: ReturnType<typeof envelope<typeof actions.blur>>
40
+ fill: ReturnType<typeof envelope<typeof actions.fill>>
41
+ form: ReturnType<typeof envelope<typeof actions.form>>
42
+ press: ReturnType<typeof envelope<typeof actions.press>>
43
+ select: ReturnType<typeof envelope<typeof actions.select>>
44
+ check: ReturnType<typeof envelope<typeof actions.check>>
45
+ }
46
+ navigate: ReturnType<typeof envelope<typeof navigate>>
47
+ back: ReturnType<typeof envelope<typeof back>>
48
+ reload: ReturnType<typeof envelope<typeof reload>>
49
+ wait: typeof disabled
50
+ state: typeof disabled
51
+ nav: typeof disabled
52
+ api: { list: typeof disabled; call: typeof disabled }
53
+ errors: typeof disabled
54
+ }
55
+
56
+ export function createBrustRuntime(): BrustRuntime {
57
+ return {
58
+ version: { api: 1, brust: packageJson.version },
59
+ pages: envelope(pages),
60
+ struct: envelope(struct),
61
+ action: {
62
+ click: envelope(actions.click),
63
+ focus: envelope(actions.focus),
64
+ blur: envelope(actions.blur),
65
+ fill: envelope(actions.fill),
66
+ form: envelope(actions.form),
67
+ press: envelope(actions.press),
68
+ select: envelope(actions.select),
69
+ check: envelope(actions.check),
70
+ },
71
+ navigate: envelope(navigate),
72
+ back: envelope(back),
73
+ reload: envelope(reload),
74
+ wait: disabled,
75
+ state: disabled,
76
+ nav: disabled,
77
+ api: { list: disabled, call: disabled },
78
+ errors: disabled,
79
+ }
80
+ }
81
+
82
+ declare global {
83
+ interface Window {
84
+ Brust: BrustRuntime
85
+ }
86
+ }
87
+
88
+ if (typeof window !== 'undefined') window.Brust = createBrustRuntime()
89
+
90
+ export type { BrustError }
@@ -0,0 +1,68 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { FlatRoute } from '../routes.ts'
4
+
5
+ export interface AiPageEntry {
6
+ path: string
7
+ params: string[]
8
+ catchAll: boolean
9
+ kind: 'react' | 'native' | 'md'
10
+ shellId: string
11
+ title?: string
12
+ description?: string
13
+ }
14
+
15
+ export interface AiManifest {
16
+ version: 1
17
+ pages: AiPageEntry[]
18
+ }
19
+
20
+ type FlatRouteLeaf = FlatRoute['chain'][number] & { __mdSource?: unknown }
21
+
22
+ const MANIFEST_PATH = '.brust/ai-manifest.json'
23
+
24
+ export function extractAiManifest(routes: FlatRoute[]): AiManifest {
25
+ const pages = routes
26
+ .map((r): AiPageEntry => {
27
+ const leaf = r.chain[r.chain.length - 1] as FlatRouteLeaf | undefined
28
+ const path = r.fullPath
29
+ const params = Array.from(path.matchAll(/\{(\*?)([^}]+)\}/g), (m) => m[2])
30
+ const kind: AiPageEntry['kind'] = leaf?.__mdSource
31
+ ? 'md'
32
+ : r.nativeTemplate
33
+ ? 'native'
34
+ : 'react'
35
+ return {
36
+ path,
37
+ params,
38
+ catchAll: r.notFound === true,
39
+ kind,
40
+ shellId: r.shellId,
41
+ }
42
+ })
43
+ .sort((a, b) => a.path.localeCompare(b.path))
44
+ return { version: 1, pages }
45
+ }
46
+
47
+ export async function writeManifest(cwd: string, manifest: AiManifest): Promise<void> {
48
+ const path = join(cwd, MANIFEST_PATH)
49
+ await mkdir(join(cwd, '.brust'), { recursive: true })
50
+ await Bun.write(path, JSON.stringify(manifest, null, 2))
51
+ }
52
+
53
+ export async function readManifest(cwd: string): Promise<AiManifest | null> {
54
+ const path = join(cwd, MANIFEST_PATH)
55
+ const file = Bun.file(path)
56
+ if (!(await file.exists())) return null
57
+ const text = await file.text()
58
+ let parsed: unknown
59
+ try {
60
+ parsed = JSON.parse(text)
61
+ } catch (e) {
62
+ throw new Error(`ai-manifest.json is malformed: ${e instanceof Error ? e.message : String(e)}`)
63
+ }
64
+ if (!parsed || typeof parsed !== 'object' || (parsed as { version?: unknown }).version !== 1) {
65
+ throw new Error(`ai-manifest.json version mismatch (expected 1)`)
66
+ }
67
+ return parsed as AiManifest
68
+ }
@@ -0,0 +1,157 @@
1
+ import { navigate as brustNavigate } from '../navigation/navigate.ts'
2
+ import { _getNavigator, getNavState, subscribe } from '../navigation/store.ts'
3
+ import { struct, type PageStruct } from './struct.ts'
4
+ import type { ErrorResult } from './refs.ts'
5
+
6
+ export interface NavigateOptions {
7
+ struct?: boolean
8
+ timeout?: number
9
+ }
10
+
11
+ export interface NavResult {
12
+ ok: true
13
+ url: string
14
+ status: 'spa' | 'full-load' | 'external'
15
+ struct?: PageStruct
16
+ }
17
+
18
+ function failure(code: 'timeout' | 'nav-failed', message: string, hint?: string): ErrorResult {
19
+ return { ok: false, error: { code, message, ...(hint ? { hint } : {}) } }
20
+ }
21
+
22
+ function allIslandsRegistered(): boolean {
23
+ return document.querySelector('[data-brust-island]:not([data-brust-hydrated])') === null
24
+ }
25
+
26
+ // One paint tick. requestAnimationFrame alone is NOT a safe await: hidden and
27
+ // backgrounded tabs (exactly where agent-driven browsers run) pause rAF
28
+ // indefinitely, so every settle would hang. Race it against a short timer —
29
+ // whichever fires first is "the page had a chance to react".
30
+ function nextTick(): Promise<void> {
31
+ return new Promise<void>((resolve) => {
32
+ const timer = setTimeout(resolve, 50)
33
+ requestAnimationFrame(() => {
34
+ clearTimeout(timer)
35
+ resolve()
36
+ })
37
+ })
38
+ }
39
+
40
+ async function waitForHydration(timeout: number): Promise<ErrorResult | null> {
41
+ const started = performance.now()
42
+ while (!allIslandsRegistered()) {
43
+ if (performance.now() - started >= timeout) {
44
+ return failure('timeout', `navigation did not hydrate within ${timeout}ms`)
45
+ }
46
+ await nextTick()
47
+ }
48
+ return null
49
+ }
50
+
51
+ async function waitForTerminal(timeout: number): Promise<ErrorResult | null> {
52
+ const current = getNavState()
53
+ if (current.phase === 'error') {
54
+ return failure('nav-failed', current.error?.message ?? 'navigation failed')
55
+ }
56
+ if (current.phase === 'success' || current.phase === 'idle') return null
57
+ return new Promise((resolve) => {
58
+ const timer = setTimeout(() => {
59
+ off()
60
+ resolve(failure('timeout', `navigation did not settle within ${timeout}ms`))
61
+ }, timeout)
62
+ const off = subscribe((state) => {
63
+ if (state.phase === 'loading') return
64
+ clearTimeout(timer)
65
+ off()
66
+ resolve(
67
+ state.phase === 'error'
68
+ ? failure('nav-failed', state.error?.message ?? 'navigation failed')
69
+ : null,
70
+ )
71
+ })
72
+ })
73
+ }
74
+
75
+ async function finish(
76
+ status: NavResult['status'],
77
+ options: NavigateOptions,
78
+ url = location.href,
79
+ ): Promise<NavResult | ErrorResult> {
80
+ const timeout = options.timeout ?? 10_000
81
+ if (status === 'spa') {
82
+ const terminalError = await waitForTerminal(timeout)
83
+ if (terminalError) return terminalError
84
+ const hydrationError = await waitForHydration(timeout)
85
+ if (hydrationError) return hydrationError
86
+ }
87
+ const result: NavResult = { ok: true, url, status }
88
+ if (options.struct && status === 'spa') {
89
+ const snapshot = await struct()
90
+ if ('ok' in snapshot && snapshot.ok === false) return snapshot
91
+ result.struct = snapshot
92
+ }
93
+ return result
94
+ }
95
+
96
+ export async function navigate(
97
+ path: string,
98
+ options: NavigateOptions = {},
99
+ ): Promise<NavResult | ErrorResult> {
100
+ const destination = new URL(path, location.href)
101
+ if (destination.origin !== location.origin) {
102
+ const response = await finish('external', options, destination.href)
103
+ setTimeout(() => location.assign(destination.href), 0)
104
+ return response
105
+ }
106
+ const hasSpaNavigator = _getNavigator() !== null
107
+ if (!hasSpaNavigator) {
108
+ const response = await finish('full-load', options, destination.href)
109
+ setTimeout(() => location.assign(destination.href), 0)
110
+ return response
111
+ }
112
+ await brustNavigate(destination.href)
113
+ // Bootstrap leaves the store in loading when it selected a full-document
114
+ // load; a committed SPA swap updates its path and terminal phase to success.
115
+ const state = getNavState()
116
+ const fullLoad = state.phase === 'loading' && state.path !== destination.pathname
117
+ return finish(fullLoad ? 'full-load' : 'spa', options)
118
+ }
119
+
120
+ async function browserHistoryAction(
121
+ run: () => void,
122
+ options: NavigateOptions = {},
123
+ ): Promise<NavResult | ErrorResult> {
124
+ const before = location.href
125
+ run()
126
+ await Promise.resolve()
127
+ await nextTick()
128
+ if (location.href === before && getNavState().phase !== 'loading') {
129
+ return finish('spa', options)
130
+ }
131
+ const state = getNavState()
132
+ const canonicalPath = location.pathname.length > 1 ? location.pathname.replace(/\/+$/, '') : '/'
133
+ const status = state.path === canonicalPath ? 'spa' : 'full-load'
134
+ return finish(status, options)
135
+ }
136
+
137
+ export function back(options?: NavigateOptions): Promise<NavResult | ErrorResult> {
138
+ return browserHistoryAction(() => history.back(), options)
139
+ }
140
+
141
+ export function reload(options?: NavigateOptions): Promise<NavResult | ErrorResult> {
142
+ return finish('full-load', options ?? {}).then((response) => {
143
+ setTimeout(() => location.reload(), 0)
144
+ return response
145
+ })
146
+ }
147
+
148
+ export async function settleAfterAction(timeout = 10_000): Promise<ErrorResult | null> {
149
+ await Promise.resolve()
150
+ await nextTick()
151
+ if (getNavState().phase === 'loading') {
152
+ const navError = await waitForTerminal(timeout)
153
+ if (navError) return navError
154
+ return waitForHydration(timeout)
155
+ }
156
+ return null
157
+ }
@@ -0,0 +1,49 @@
1
+ import type { ErrorResult } from './refs.ts'
2
+
3
+ export interface PageEntry {
4
+ path: string
5
+ params: string[]
6
+ catchAll: boolean
7
+ kind: 'react' | 'native' | 'md'
8
+ shellId: string
9
+ title?: string
10
+ description?: string
11
+ }
12
+
13
+ interface AiManifest {
14
+ version: 1
15
+ pages: PageEntry[]
16
+ }
17
+
18
+ let pagesPromise: Promise<PageEntry[] | ErrorResult> | null = null
19
+
20
+ function notFound(message: string): ErrorResult {
21
+ return {
22
+ ok: false,
23
+ error: {
24
+ code: 'not-found',
25
+ message,
26
+ hint: 'ai manifest not served — is the ai flag enabled?',
27
+ },
28
+ }
29
+ }
30
+
31
+ export function pages(): Promise<PageEntry[] | ErrorResult> {
32
+ if (!pagesPromise) {
33
+ pagesPromise = fetch('/_brust/ai/manifest.json', { headers: { Accept: 'application/json' } })
34
+ .then(async (response) => {
35
+ if (!response.ok) return notFound(`ai manifest request failed with HTTP ${response.status}`)
36
+ const manifest = (await response.json()) as Partial<AiManifest>
37
+ if (manifest.version !== 1 || !Array.isArray(manifest.pages)) {
38
+ return notFound('ai manifest has an unsupported shape')
39
+ }
40
+ return manifest.pages
41
+ })
42
+ .catch((cause) => notFound(`ai manifest request failed: ${String(cause)}`))
43
+ }
44
+ return pagesPromise
45
+ }
46
+
47
+ export function __resetPagesForTest(): void {
48
+ pagesPromise = null
49
+ }