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 +6 -0
- package/package.json +7 -7
- package/runtime/ai/actions.ts +271 -0
- package/runtime/ai/index.ts +90 -0
- package/runtime/ai/manifest.ts +68 -0
- package/runtime/ai/navigate.ts +157 -0
- package/runtime/ai/pages.ts +49 -0
- package/runtime/ai/refs.ts +87 -0
- package/runtime/ai/struct.ts +168 -0
- package/runtime/cli/build.ts +43 -4
- package/runtime/cli/dev.ts +6 -1
- package/runtime/cli/help.ts +8 -0
- package/runtime/cli/native-routes-emit.ts +33 -9
- package/runtime/config.ts +3 -0
- package/runtime/generator.ts +14 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +88 -3
- package/runtime/islands/build.ts +42 -1
- package/runtime/md/emit.ts +5 -1
- package/runtime/native/runtime.ts +86 -61
- package/runtime/render/inject-ai-client.ts +38 -0
- package/runtime/render/stream.ts +9 -3
- package/types/ai/manifest.d.ts +17 -0
- package/types/cli/native-routes-emit.d.ts +5 -1
- package/types/config.d.ts +2 -0
- package/types/generator.d.ts +5 -0
- package/types/index.d.ts +2 -0
- package/types/islands/build.d.ts +9 -0
- package/types/md/emit.d.ts +3 -0
- package/types/native/runtime.d.ts +7 -7
- package/types/render/inject-ai-client.d.ts +6 -0
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.
|
|
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.
|
|
45
|
-
"brustjs-darwin-arm64": "0.1.
|
|
46
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
48
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
49
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
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
|
+
}
|