@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11
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 +66 -6
- package/native/index.d.ts +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +70 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/worker.ts +129 -245
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// `<brust-devtools>` (L15): the browser overlay of `brust dev`. Served by the dev proxy as `/_brust/dev/devtools.js`
|
|
2
|
+
// (bundled from this file on first request) and injected by it into HTML responses: nothing of it exists under
|
|
3
|
+
// `brust start` or in a production build. Everything it shows comes from the trace (`#__brust_trace`,
|
|
4
|
+
// `/_brust/dev/traces`), `dev.json` (`/_brust/dev/meta`), the DOM or the dev global (`__brustDev`): nothing is invented.
|
|
5
|
+
// It lives in a shadow root: no style leaks either way.
|
|
6
|
+
|
|
7
|
+
import { tierCounts, type Tier } from './devtools-data'
|
|
8
|
+
interface Diag { class: string; rule: string; line: number; col: number; message: string; remediation: string }
|
|
9
|
+
export interface DevComponent { file: string; tier: Tier; reason?: string; diagnostics: Diag[] }
|
|
10
|
+
interface DevMeta { components?: Record<string, DevComponent> }
|
|
11
|
+
interface TraceJob { call_id: string; kind: string; component_id: string; user_key: string | null; tags: string[]; hit: boolean }
|
|
12
|
+
interface Trace {
|
|
13
|
+
route: { id: string; pattern: string; chain: string[]; params: Record<string, string> } | null
|
|
14
|
+
status: number
|
|
15
|
+
cache: string
|
|
16
|
+
loader_ms: number | null
|
|
17
|
+
render_ms: number | null
|
|
18
|
+
render_error: string | null
|
|
19
|
+
bun_calls: number
|
|
20
|
+
jobs: TraceJob[]
|
|
21
|
+
jobs_more: number
|
|
22
|
+
rendered: string[]
|
|
23
|
+
truncated?: boolean
|
|
24
|
+
}
|
|
25
|
+
interface Entry { id: number; at: number; kind: string; method: string; path: string; status: number; ms: number; trace: Trace | null }
|
|
26
|
+
interface BuildState { state: 'building' | 'ready' | 'error'; gen: number; diag: { rule: string; file?: string; line?: number; col?: number; message: string } | null }
|
|
27
|
+
|
|
28
|
+
const TABS = ['Page', 'Components', 'Jobs', 'Store', 'Islands', 'Build', 'Requests'] as const
|
|
29
|
+
type TabName = (typeof TABS)[number]
|
|
30
|
+
|
|
31
|
+
const CSS = `
|
|
32
|
+
:host { all: initial; position: fixed; width: 0; height: 0; --bg: #ffffff; --fg: #1a1a1f; --muted: #4a4a55; --line: #c9c9d2; --accent: #0b57d0; --accent-fg: #ffffff;
|
|
33
|
+
--static: #0b6b3a; --native: #0b57d0; --react: #8a3b00; --err: #b3261e; --warn: #7a5300; --hl: rgba(11, 87, 208, .25); }
|
|
34
|
+
@media (prefers-color-scheme: dark) { :host { --bg: #1b1b1f; --fg: #f2f2f5; --muted: #c4c4cf; --line: #4a4a55; --accent: #8ab4f8; --accent-fg: #0a0a0c;
|
|
35
|
+
--static: #6ee7a8; --native: #8ab4f8; --react: #ffb27a; --err: #ff8a80; --warn: #ffd180; --hl: rgba(138, 180, 248, .3); } }
|
|
36
|
+
* { box-sizing: border-box; }
|
|
37
|
+
button { font: inherit; color: inherit; }
|
|
38
|
+
.ind { position: fixed; right: 12px; bottom: 12px; z-index: 2147483646; display: flex; gap: 8px; align-items: center; padding: 6px 10px; border-radius: 999px;
|
|
39
|
+
background: var(--bg); color: var(--fg); border: 1px solid var(--line); font: 12px/1.2 ui-monospace, Menlo, monospace; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.25); }
|
|
40
|
+
.ind:focus-visible, .tab:focus-visible, .x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
41
|
+
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--static); }
|
|
42
|
+
.dot.building { background: var(--warn); } .dot.error { background: var(--err); }
|
|
43
|
+
.panel { position: fixed; right: 12px; bottom: 52px; z-index: 2147483646; width: min(560px, calc(100vw - 24px)); max-height: min(70vh, 560px); display: flex; flex-direction: column;
|
|
44
|
+
background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; font: 12px/1.45 ui-monospace, Menlo, monospace; box-shadow: 0 6px 24px rgba(0,0,0,.35); }
|
|
45
|
+
.panel[hidden] { display: none; }
|
|
46
|
+
.bar { display: flex; align-items: center; border-bottom: 1px solid var(--line); }
|
|
47
|
+
.tabs { display: flex; flex: 1; overflow-x: auto; }
|
|
48
|
+
.tab { padding: 8px 10px; background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; color: var(--muted); }
|
|
49
|
+
.tab[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); }
|
|
50
|
+
.x { padding: 6px 10px; background: none; border: 0; cursor: pointer; color: var(--muted); }
|
|
51
|
+
.body { padding: 10px; overflow: auto; }
|
|
52
|
+
table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
53
|
+
th { color: var(--muted); font-weight: 600; }
|
|
54
|
+
.b { display: inline-block; padding: 0 6px; border-radius: 4px; border: 1px solid currentColor; font-weight: 700; }
|
|
55
|
+
.b.static { color: var(--static); } .b.native { color: var(--native); } .b.react { color: var(--react); }
|
|
56
|
+
.err { color: var(--err); } .warn { color: var(--warn); } .muted { color: var(--muted); }
|
|
57
|
+
ul.tree { list-style: none; margin: 0; padding-left: 14px; } ul.tree.root { padding-left: 0; }
|
|
58
|
+
.node { padding: 2px 4px; border-radius: 4px; cursor: default; } .node:hover, .node:focus { background: var(--hl); outline: none; }
|
|
59
|
+
.hl { position: fixed; z-index: 2147483645; pointer-events: none; background: var(--hl); border: 2px solid var(--accent); border-radius: 3px; }
|
|
60
|
+
code { font: inherit; } h3 { margin: 0 0 6px; font-size: 12px; }
|
|
61
|
+
`
|
|
62
|
+
|
|
63
|
+
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, ...kids: (Node | string)[]): HTMLElementTagNameMap[K] => {
|
|
64
|
+
const e = document.createElement(tag)
|
|
65
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v)
|
|
66
|
+
for (const k of kids) e.append(k)
|
|
67
|
+
return e
|
|
68
|
+
}
|
|
69
|
+
const badge = (t: Tier | 'unknown') => el('span', { class: `b ${t}` }, t)
|
|
70
|
+
const ms = (n: number | null | undefined) => (typeof n === 'number' ? `${n.toFixed(2)} ms` : '—')
|
|
71
|
+
|
|
72
|
+
class BrustDevtools extends HTMLElement {
|
|
73
|
+
private root = this.attachShadow({ mode: 'open' })
|
|
74
|
+
private ind!: HTMLButtonElement
|
|
75
|
+
private panel!: HTMLElement
|
|
76
|
+
private body!: HTMLElement
|
|
77
|
+
private tabs: HTMLButtonElement[] = []
|
|
78
|
+
private hl = el('div', { class: 'hl', hidden: '' })
|
|
79
|
+
private tab: TabName = 'Page'
|
|
80
|
+
private opener: Element | null = null
|
|
81
|
+
private meta: DevMeta | null = null
|
|
82
|
+
private entries: Entry[] = []
|
|
83
|
+
private build: BuildState = { state: 'building', gen: 0, diag: null }
|
|
84
|
+
private trace: Trace | null = null
|
|
85
|
+
|
|
86
|
+
connectedCallback(): void {
|
|
87
|
+
this.trace = readTrace()
|
|
88
|
+
const style = el('style'); style.textContent = CSS
|
|
89
|
+
this.ind = el('button', { class: 'ind', type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', 'aria-label': 'brust devtools' })
|
|
90
|
+
this.panel = el('div', { class: 'panel', role: 'dialog', 'aria-label': 'brust devtools', hidden: '' })
|
|
91
|
+
const tablist = el('div', { class: 'tabs', role: 'tablist', 'aria-label': 'devtools panels' })
|
|
92
|
+
for (const t of TABS) {
|
|
93
|
+
const b = el('button', { class: 'tab', type: 'button', role: 'tab', id: `tab-${t}`, 'aria-selected': 'false', tabindex: '-1', 'aria-controls': 'body' }, t)
|
|
94
|
+
b.onclick = () => this.select(t)
|
|
95
|
+
this.tabs.push(b); tablist.append(b)
|
|
96
|
+
}
|
|
97
|
+
const close = el('button', { class: 'x', type: 'button', 'aria-label': 'Close devtools' }, '✕')
|
|
98
|
+
close.onclick = () => this.close()
|
|
99
|
+
this.body = el('div', { class: 'body', id: 'body', role: 'tabpanel' })
|
|
100
|
+
this.panel.append(el('div', { class: 'bar' }, tablist, close), this.body)
|
|
101
|
+
this.root.append(style, this.hl, this.ind, this.panel)
|
|
102
|
+
this.ind.onclick = () => this.toggle()
|
|
103
|
+
tablist.onkeydown = (e) => this.tabKeys(e)
|
|
104
|
+
this.panel.onkeydown = (e) => { if (e.key === 'Escape') { e.stopPropagation(); this.close() } }
|
|
105
|
+
window.addEventListener('keydown', (e) => { if (e.altKey && e.shiftKey && (e.key === 'B' || e.key === 'b' || e.code === 'KeyB')) { e.preventDefault(); this.toggle() } })
|
|
106
|
+
this.paintIndicator()
|
|
107
|
+
void this.loadMeta()
|
|
108
|
+
this.connect()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async loadMeta(): Promise<void> {
|
|
112
|
+
try { this.meta = (await (await fetch('/_brust/dev/meta')).json()) as DevMeta } catch { this.meta = null }
|
|
113
|
+
this.paintIndicator()
|
|
114
|
+
if (!this.panel.hidden) this.render()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async loadEntries(): Promise<void> {
|
|
118
|
+
try { this.entries = ((await (await fetch('/_brust/dev/traces')).json()) as { traces: Entry[] }).traces } catch { this.entries = [] }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private connect(): void {
|
|
122
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_brust/dev/ws`)
|
|
123
|
+
ws.onmessage = (e) => {
|
|
124
|
+
const m = JSON.parse(e.data as string) as { type: string; gen?: number; state?: BuildState['state']; diag?: BuildState['diag'] }
|
|
125
|
+
if (m.type === 'hello') this.build = { state: m.state ?? 'ready', gen: m.gen ?? 0, diag: m.diag ?? null }
|
|
126
|
+
else if (m.type === 'error') this.build = { state: 'error', gen: this.build.gen, diag: m.diag ?? null }
|
|
127
|
+
else return
|
|
128
|
+
this.paintIndicator()
|
|
129
|
+
if (!this.panel.hidden && this.tab === 'Build') this.render()
|
|
130
|
+
}
|
|
131
|
+
ws.onclose = () => setTimeout(() => this.connect(), 1000)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private counts(): Record<Tier, number> { return tierCounts(this.trace?.rendered ?? [], this.meta) }
|
|
135
|
+
|
|
136
|
+
private paintIndicator(): void {
|
|
137
|
+
const c = this.counts()
|
|
138
|
+
this.ind.replaceChildren(el('span', { class: `dot ${this.build.state}`, 'aria-hidden': 'true' }), `${this.build.state} · ${c.static}s ${c.native}n ${c.react}r`)
|
|
139
|
+
this.ind.setAttribute('data-state', this.build.state)
|
|
140
|
+
this.ind.title = `static ${c.static} · native ${c.native} · react ${c.react} (Alt+Shift+B)`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
toggle(): void { if (this.panel.hidden) this.open(); else this.close() }
|
|
144
|
+
|
|
145
|
+
open(): void {
|
|
146
|
+
this.opener = deepActive()
|
|
147
|
+
this.panel.hidden = false
|
|
148
|
+
this.ind.setAttribute('aria-expanded', 'true')
|
|
149
|
+
void this.loadEntries().then(() => { if (this.tab === 'Requests') this.render() })
|
|
150
|
+
this.select(this.tab)
|
|
151
|
+
this.tabs[TABS.indexOf(this.tab)]?.focus()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
close(): void {
|
|
155
|
+
this.panel.hidden = true
|
|
156
|
+
this.hl.hidden = true
|
|
157
|
+
this.ind.setAttribute('aria-expanded', 'false')
|
|
158
|
+
const back = this.opener instanceof HTMLElement && this.opener !== document.body && this.opener.isConnected && !this.contains(this.opener) ? this.opener : this.ind
|
|
159
|
+
back.focus()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private tabKeys(e: KeyboardEvent): void {
|
|
163
|
+
const i = TABS.indexOf(this.tab)
|
|
164
|
+
const to = e.key === 'ArrowRight' ? (i + 1) % TABS.length : e.key === 'ArrowLeft' ? (i + TABS.length - 1) % TABS.length : e.key === 'Home' ? 0 : e.key === 'End' ? TABS.length - 1 : -1
|
|
165
|
+
if (to < 0) return
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
this.select(TABS[to] as TabName)
|
|
168
|
+
this.tabs[to]?.focus()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private select(t: TabName): void {
|
|
172
|
+
this.tab = t
|
|
173
|
+
this.tabs.forEach((b, i) => { const on = TABS[i] === t; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1 })
|
|
174
|
+
this.body.setAttribute('aria-labelledby', `tab-${t}`)
|
|
175
|
+
this.render()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private render(): void {
|
|
179
|
+
const t = this.trace
|
|
180
|
+
const kids: Node[] = []
|
|
181
|
+
switch (this.tab) {
|
|
182
|
+
case 'Page': kids.push(this.pagePanel(t)); break
|
|
183
|
+
case 'Components': kids.push(this.componentsPanel(t)); break
|
|
184
|
+
case 'Jobs': kids.push(this.jobsPanel(t)); break
|
|
185
|
+
case 'Store': kids.push(this.storePanel()); break
|
|
186
|
+
case 'Islands': kids.push(this.islandsPanel()); break
|
|
187
|
+
case 'Build': kids.push(this.buildPanel()); break
|
|
188
|
+
case 'Requests': kids.push(this.requestsPanel()); break
|
|
189
|
+
}
|
|
190
|
+
this.body.replaceChildren(...kids)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private kv(rows: [string, string | Node][]): HTMLElement {
|
|
194
|
+
const tb = el('table')
|
|
195
|
+
for (const [k, v] of rows) tb.append(el('tr', {}, el('th', { scope: 'row' }, k), el('td', {}, v)))
|
|
196
|
+
return tb
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private pagePanel(t: Trace | null): Node {
|
|
200
|
+
if (!t?.route) return el('p', { class: 'muted' }, 'No trace for this page (not served through a traced core, or the route did not match).')
|
|
201
|
+
const params = Object.entries(t.route.params)
|
|
202
|
+
return this.kv([
|
|
203
|
+
['route', `${t.route.id} ${t.route.pattern}`],
|
|
204
|
+
['params', params.length ? params.map(([k, v]) => `${k}=${v}`).join(', ') : '—'],
|
|
205
|
+
['status', String(t.status)],
|
|
206
|
+
['cache', t.cache === '-' ? 'none (this route has no cache)' : t.cache],
|
|
207
|
+
['loader', ms(t.loader_ms)],
|
|
208
|
+
['render', ms(t.render_ms)],
|
|
209
|
+
['worker calls', String(t.bun_calls)],
|
|
210
|
+
['render error', t.render_error ?? '—'],
|
|
211
|
+
...(t.truncated ? ([['trace', 'truncated to fit the header']] as [string, string][]) : []),
|
|
212
|
+
])
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private componentsPanel(t: Trace | null): Node {
|
|
216
|
+
const chain = t?.route?.chain ?? []
|
|
217
|
+
if (chain.length === 0) return el('p', { class: 'muted' }, 'No component data for this page.')
|
|
218
|
+
const extras = (t?.rendered ?? []).filter((id) => !chain.includes(id))
|
|
219
|
+
const node = (id: string, nested: Node | null): HTMLElement => {
|
|
220
|
+
const c = this.meta?.components?.[id]
|
|
221
|
+
const row = el('div', { class: 'node', tabindex: '0', 'data-component': id }, badge(c?.tier ?? 'unknown'), ' ', el('code', {}, id), c ? el('span', { class: 'muted' }, ` ${c.file}`) : '')
|
|
222
|
+
if (c?.tier === 'react') {
|
|
223
|
+
row.append(el('div', { class: 'muted' }, `reason: ${c.reason ?? '—'}`))
|
|
224
|
+
for (const d of c.diagnostics.filter((x) => x.class !== 'error')) row.append(el('div', { class: 'warn' }, `${c.file}:${d.line}:${d.col} ${d.rule}`))
|
|
225
|
+
}
|
|
226
|
+
const on = () => this.highlight(id)
|
|
227
|
+
row.onmouseenter = on; row.onfocus = on
|
|
228
|
+
row.onmouseleave = () => { this.hl.hidden = true }; row.onblur = () => { this.hl.hidden = true }
|
|
229
|
+
const li = el('li', {}, row)
|
|
230
|
+
if (nested) li.append(nested)
|
|
231
|
+
return li
|
|
232
|
+
}
|
|
233
|
+
let tree: Node | null = null
|
|
234
|
+
for (const id of [...chain].reverse()) tree = el('ul', { class: 'tree' }, node(id, tree))
|
|
235
|
+
const rootList = el('ul', { class: 'tree root' })
|
|
236
|
+
rootList.append(...(tree ? [...tree.childNodes] : []))
|
|
237
|
+
const out = el('div', {}, rootList)
|
|
238
|
+
if (extras.length) out.append(el('h3', {}, 'Also on the page (children)'), el('ul', { class: 'tree root' }, ...extras.map((id) => node(id, null))))
|
|
239
|
+
return out
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Highlight a component on the page: chain marker (Range between `<!--brust:id-->` and its close), native `[x-data]`, island `brust-island`. */
|
|
243
|
+
private highlight(id: string): void {
|
|
244
|
+
const rects = componentRects(id)
|
|
245
|
+
if (rects.length === 0) { this.hl.hidden = true; return }
|
|
246
|
+
const r = rects[0] as DOMRect
|
|
247
|
+
Object.assign(this.hl.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` })
|
|
248
|
+
this.hl.hidden = false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private jobsPanel(t: Trace | null): Node {
|
|
252
|
+
if (!t) return el('p', { class: 'muted' }, 'No trace for this page.')
|
|
253
|
+
if (t.jobs.length === 0) return el('p', { class: 'muted' }, t.cache === 'HIT' ? 'A cache HIT: no jobs ran.' : 'No jobs on this page.')
|
|
254
|
+
const tb = el('table', {}, el('tr', {}, ...['call', 'kind', 'result', 'key / tags'].map((h) => el('th', {}, h))))
|
|
255
|
+
for (const j of t.jobs) tb.append(el('tr', {}, el('td', {}, j.call_id), el('td', {}, j.kind), el('td', {}, j.hit ? 'hit' : 'miss'), el('td', {}, [j.user_key, ...j.tags].filter(Boolean).join(', ') || '—')))
|
|
256
|
+
const out = el('div', {}, tb)
|
|
257
|
+
if (t.jobs_more) out.append(el('p', { class: 'muted' }, `+${t.jobs_more} more not listed`))
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private storePanel(): Node {
|
|
262
|
+
const seeds = [...document.querySelectorAll('script[data-brust-store]')]
|
|
263
|
+
if (seeds.length === 0) return el('p', { class: 'muted' }, 'No stores on this page.')
|
|
264
|
+
const reg = (globalThis as Record<symbol, Record<string, { instance: Record<string, unknown> }> | undefined>)[Symbol.for('brust.stores')]
|
|
265
|
+
const out = el('div')
|
|
266
|
+
for (const s of seeds) {
|
|
267
|
+
const name = s.getAttribute('data-brust-store') ?? '?'
|
|
268
|
+
let seed: unknown = null
|
|
269
|
+
try { seed = JSON.parse(s.textContent ?? 'null') } catch { seed = s.textContent }
|
|
270
|
+
out.append(el('h3', {}, name), this.kv([['seed (SSR)', JSON.stringify(seed)], ['live', reg?.[name] ? JSON.stringify(liveValues(reg[name].instance)) : 'not mounted natively (no native component read it)']]))
|
|
271
|
+
}
|
|
272
|
+
return out
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private islandsPanel(): Node {
|
|
276
|
+
const islands = [...document.querySelectorAll('brust-island')]
|
|
277
|
+
if (islands.length === 0) return el('p', { class: 'muted' }, 'No React islands on this page.')
|
|
278
|
+
const tb = el('table', {}, el('tr', {}, ...['island', 'hydrated'].map((h) => el('th', {}, h))))
|
|
279
|
+
for (const i of islands) tb.append(el('tr', {}, el('td', {}, i.getAttribute('data-id') ?? '?'), el('td', { class: i.getAttribute('data-hydrated') === '1' ? '' : 'warn' }, i.getAttribute('data-hydrated') === '1' ? 'yes' : 'no')))
|
|
280
|
+
return tb
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private buildPanel(): Node {
|
|
284
|
+
const out = el('div', {}, el('p', {}, `build: ${this.build.state} (generation ${this.build.gen})`))
|
|
285
|
+
const d = this.build.diag
|
|
286
|
+
if (d) out.append(el('p', { class: 'err' }, `${d.rule}${d.file ? ` ${d.file}${d.line ? `:${d.line}${d.col ? `:${d.col}` : ''}` : ''}` : ''} ${d.message}`))
|
|
287
|
+
const rows: Node[] = []
|
|
288
|
+
for (const [id, c] of Object.entries(this.meta?.components ?? {})) {
|
|
289
|
+
for (const x of c.diagnostics) rows.push(el('tr', {}, el('td', { class: x.class === 'error' ? 'err' : 'warn' }, x.class), el('td', {}, `${c.file}:${x.line}:${x.col}`), el('td', {}, x.rule), el('td', {}, x.message + (x.remediation ? ` — ${x.remediation}` : '')), el('td', { class: 'muted' }, id)))
|
|
290
|
+
}
|
|
291
|
+
if (rows.length === 0) out.append(el('p', { class: 'muted' }, 'No warnings.'))
|
|
292
|
+
else out.append(el('table', {}, el('tr', {}, ...['class', 'location', 'rule', 'message', 'component'].map((h) => el('th', {}, h))), ...rows))
|
|
293
|
+
return out
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private requestsPanel(): Node {
|
|
297
|
+
if (this.entries.length === 0) return el('p', { class: 'muted' }, 'No traced requests yet.')
|
|
298
|
+
const tb = el('table', {}, el('tr', {}, ...['', 'request', 'status', 'ms', 'cache'].map((h) => el('th', {}, h))))
|
|
299
|
+
for (const e of [...this.entries].reverse()) tb.append(el('tr', {}, el('td', { class: 'muted' }, e.kind), el('td', {}, `${e.method} ${e.path}`), el('td', { class: e.status >= 500 ? 'err' : '' }, String(e.status)), el('td', {}, String(e.ms)), el('td', {}, e.trace?.cache ?? '—')))
|
|
300
|
+
return tb
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The focused element, looking through shadow roots (the devtools' own buttons live in one). */
|
|
305
|
+
function deepActive(): Element | null {
|
|
306
|
+
let a: Element | null = document.activeElement
|
|
307
|
+
while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement
|
|
308
|
+
return a
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readTrace(): Trace | null {
|
|
312
|
+
try { return JSON.parse(document.getElementById('__brust_trace')?.textContent ?? 'null') as Trace | null } catch { return null }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Live values of a store instance. Only signals and computeds are READ (marked by the runtime with `Symbol.for`); actions are never called. */
|
|
316
|
+
function liveValues(instance: Record<string, unknown>): Record<string, unknown> {
|
|
317
|
+
const SIGNAL = Symbol.for('brust.signal'); const COMPUTED = Symbol.for('brust.computed')
|
|
318
|
+
const out: Record<string, unknown> = {}
|
|
319
|
+
for (const [k, v] of Object.entries(instance)) {
|
|
320
|
+
if (typeof v !== 'function') continue
|
|
321
|
+
const f = v as unknown as Record<symbol, unknown> & (() => unknown)
|
|
322
|
+
if (f[SIGNAL] === true || f[COMPUTED] === true) out[k] = f()
|
|
323
|
+
}
|
|
324
|
+
return out
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Client rects of component `id` on the page. */
|
|
328
|
+
export function componentRects(id: string): DOMRect[] {
|
|
329
|
+
const rects: DOMRect[] = []
|
|
330
|
+
for (const e of document.querySelectorAll(`[x-data]`)) if (e.getAttribute('x-data') === id) rects.push(e.getBoundingClientRect())
|
|
331
|
+
for (const e of document.querySelectorAll('brust-island')) if (e.getAttribute('data-id') === id) rects.push(e.getBoundingClientRect())
|
|
332
|
+
if (rects.length) return rects
|
|
333
|
+
// A chain level: the nodes between `<!--brust:id-->` and its matching `<!--/brust-->`.
|
|
334
|
+
const w = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT)
|
|
335
|
+
let open: Comment | null = null
|
|
336
|
+
let depth = 0
|
|
337
|
+
for (let n = w.nextNode() as Comment | null; n; n = w.nextNode() as Comment | null) {
|
|
338
|
+
const v = n.nodeValue ?? ''
|
|
339
|
+
if (!open) { if (v === `brust:${id}`) { open = n; depth = 1 } continue }
|
|
340
|
+
if (v.startsWith('brust:')) depth++
|
|
341
|
+
else if (v === '/brust' && --depth === 0) {
|
|
342
|
+
const r = document.createRange()
|
|
343
|
+
r.setStartAfter(open); r.setEndBefore(n)
|
|
344
|
+
const box = r.getBoundingClientRect()
|
|
345
|
+
return box.width || box.height ? [box] : []
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return []
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (typeof customElements !== 'undefined' && !customElements.get('brust-devtools')) {
|
|
352
|
+
customElements.define('brust-devtools', BrustDevtools)
|
|
353
|
+
const mount = () => { if (!document.querySelector('brust-devtools')) document.body.append(document.createElement('brust-devtools')) }
|
|
354
|
+
if (document.body) mount()
|
|
355
|
+
else addEventListener('DOMContentLoaded', mount)
|
|
356
|
+
}
|
package/src/dev/diag.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Parsing `brust build` output for the dev server: `error <rule> <file:line:col> <message>` (cli.ts) and
|
|
2
|
+
// `warning <rule> <file:line:col> <message>` (compile.ts).
|
|
3
|
+
export interface Diag {
|
|
4
|
+
rule: string
|
|
5
|
+
file?: string
|
|
6
|
+
line?: number
|
|
7
|
+
col?: number
|
|
8
|
+
message: string
|
|
9
|
+
/** The whole error text (stack, extra lines). */
|
|
10
|
+
raw: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The first `error …` line of `out` and everything after it; null when there is none. */
|
|
14
|
+
export function parseBuildError(out: string): Diag | null {
|
|
15
|
+
const i = out.search(/^error /m)
|
|
16
|
+
if (i < 0) return null
|
|
17
|
+
const raw = out.slice(i).trim()
|
|
18
|
+
const m = /^error (\S+) (?:(\S+?):(\d+):(\d+)[: ]\s*)?([^\n]*)/.exec(raw)
|
|
19
|
+
if (!m) return { rule: 'build', message: raw.split('\n')[0] ?? raw, raw }
|
|
20
|
+
const [, rule, file, line, col, message] = m
|
|
21
|
+
return { rule: rule ?? 'build', ...(file ? { file, line: Number(line), col: Number(col) } : {}), message: message ?? '', raw }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every `warning …` line of `out`, without the `warning ` prefix. */
|
|
25
|
+
export function parseWarnings(out: string): string[] {
|
|
26
|
+
return out
|
|
27
|
+
.split('\n')
|
|
28
|
+
.filter((l) => l.startsWith('warning '))
|
|
29
|
+
.map((l) => l.slice('warning '.length).trim())
|
|
30
|
+
}
|
package/src/dev/proxy.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// The long-lived parent of `brust dev` for the Bun target (R13.1, R13.2): owns the user's port with
|
|
2
|
+
// `Bun.serve`, proxies to the active child (`brust start` on a loopback port), injects the dev client
|
|
3
|
+
// into every HTML response and serves the WebSocket the client listens on.
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { Server, ServerWebSocket } from 'bun'
|
|
6
|
+
import { DEV_CLIENT_JS } from './client'
|
|
7
|
+
import type { Diag } from './diag'
|
|
8
|
+
import type { DevState } from './tui'
|
|
9
|
+
|
|
10
|
+
export interface DevView {
|
|
11
|
+
gen(): number
|
|
12
|
+
state(): DevState
|
|
13
|
+
diag(): Diag | null
|
|
14
|
+
/** The active child's loopback port; null before the first successful boot. */
|
|
15
|
+
upstream(): number | null
|
|
16
|
+
onRequest(r: { method: string; path: string; status: number; ms: number; cache: string | null; trace: unknown | null }): void
|
|
17
|
+
/** The active build's `dev.json` (L14), or null before the first build. */
|
|
18
|
+
meta(): unknown | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One entry of the dev trace list (`/_brust/dev/traces`, newest last, at most [`MAX_TRACES`]). */
|
|
22
|
+
export interface TraceEntry {
|
|
23
|
+
id: number
|
|
24
|
+
at: number
|
|
25
|
+
kind: 'page' | 'action' | 'request'
|
|
26
|
+
method: string
|
|
27
|
+
path: string
|
|
28
|
+
status: number
|
|
29
|
+
ms: number
|
|
30
|
+
/** The core's trace (`x-brust-dev`), pages only. */
|
|
31
|
+
trace: unknown | null
|
|
32
|
+
}
|
|
33
|
+
export const MAX_TRACES = 50
|
|
34
|
+
|
|
35
|
+
export interface DevProxy {
|
|
36
|
+
server: Server<undefined>
|
|
37
|
+
port: number
|
|
38
|
+
/** The last [`MAX_TRACES`] traced requests, oldest first. */
|
|
39
|
+
traces(): TraceEntry[]
|
|
40
|
+
/** Send to every connected browser. */
|
|
41
|
+
broadcast(msg: Record<string, unknown>): void
|
|
42
|
+
clients(): number
|
|
43
|
+
stop(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CLIENT_PATH = '/_brust/dev/client.js'
|
|
47
|
+
export const WS_PATH = '/_brust/dev/ws'
|
|
48
|
+
export const DEVTOOLS_PATH = '/_brust/dev/devtools.js'
|
|
49
|
+
export const TRACES_PATH = '/_brust/dev/traces'
|
|
50
|
+
export const META_PATH = '/_brust/dev/meta'
|
|
51
|
+
const scriptTag = (gen: number) => `<script type="module" src="${CLIENT_PATH}?g=${gen}"></script><script type="module" src="${DEVTOOLS_PATH}"></script>`
|
|
52
|
+
|
|
53
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] as string)
|
|
54
|
+
|
|
55
|
+
/** `html` with the dev client script before `</head>` (else at the end). */
|
|
56
|
+
export function inject(html: string, gen: number, trace: unknown | null = null): string {
|
|
57
|
+
const tag = scriptTag(gen) + (trace ? traceTag(trace) : '')
|
|
58
|
+
const i = html.search(/<\/head\s*>/i)
|
|
59
|
+
return i >= 0 ? html.slice(0, i) + tag + html.slice(i) : html + tag
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isDocument(req: Request): boolean {
|
|
63
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false
|
|
64
|
+
const dest = req.headers.get('sec-fetch-dest')
|
|
65
|
+
if (dest) return dest === 'document'
|
|
66
|
+
return (req.headers.get('accept') ?? '').includes('text/html')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `<script type=application/json id=__brust_trace>` for a traced page (`<` escaped: it cannot close the tag). */
|
|
70
|
+
const traceTag = (trace: unknown) => `<script type="application/json" id="__brust_trace">${JSON.stringify(trace).replace(/</g, '\\u003c')}</script>`
|
|
71
|
+
|
|
72
|
+
/** The core's `x-brust-dev` header (base64 of JSON) → the trace object; null when absent or malformed. */
|
|
73
|
+
export function decodeTrace(h: string | null): unknown | null {
|
|
74
|
+
if (!h) return null
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(Buffer.from(h, 'base64').toString('utf8'))
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A small HTML page (so even a plain-text 5xx or "no child yet" carries the client script). */
|
|
83
|
+
function htmlPage(status: number, title: string, body: string, gen: number, trace: unknown | null = null): Response {
|
|
84
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>${scriptTag(gen)}${trace ? traceTag(trace) : ''}</head><body style="font:14px ui-monospace,monospace;padding:24px"><h1>${esc(title)}</h1><pre style="white-space:pre-wrap">${esc(body)}</pre></body></html>`
|
|
85
|
+
return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
|
89
|
+
|
|
90
|
+
/** The `Host` names the dev server answers to (DNS-rebinding guard): loopback, the configured bind host, and
|
|
91
|
+
* `BRUST_DEV_ALLOWED_HOSTS` (comma separated) for a tunnel or a custom local name. */
|
|
92
|
+
export function allowedHosts(bind: string, extra: string | undefined): Set<string> {
|
|
93
|
+
return new Set([...LOOPBACK, bind.toLowerCase(), ...(extra ?? '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const hostName = (hostHeader: string): string => {
|
|
97
|
+
const h = hostHeader.toLowerCase()
|
|
98
|
+
return h.startsWith('[') ? h.slice(0, h.indexOf(']') + 1) : (h.split(':')[0] ?? '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A WebSocket (or any request) from a page of ANOTHER origin must not reach the dev channel: `Origin`, when sent,
|
|
102
|
+
* has to be the host the request was addressed to. */
|
|
103
|
+
export function sameOriginOrNone(origin: string | null, hostHeader: string | null): boolean {
|
|
104
|
+
if (origin === null) return true
|
|
105
|
+
try {
|
|
106
|
+
return new URL(origin).host.toLowerCase() === (hostHeader ?? '').toLowerCase()
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let devtoolsJs: Promise<string> | null = null
|
|
113
|
+
/** `<brust-devtools>` (L15), bundled from `devtools.ts` on first request and kept: it is only ever served by this proxy. */
|
|
114
|
+
function devtoolsBundle(): Promise<string> {
|
|
115
|
+
devtoolsJs ??= Bun.build({ entrypoints: [join(import.meta.dir, 'devtools.ts')], target: 'browser', format: 'esm', minify: false }).then(async (r) => {
|
|
116
|
+
const out = r.outputs[0]
|
|
117
|
+
if (!r.success || !out) throw new Error(`devtools bundle failed: ${r.logs.map(String).join('\n')}`)
|
|
118
|
+
return out.text()
|
|
119
|
+
})
|
|
120
|
+
return devtoolsJs
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The trace a proxied response carried (read by `onRequest`; the header itself never reaches the browser). */
|
|
124
|
+
const traceOf = new WeakMap<Response, unknown>()
|
|
125
|
+
|
|
126
|
+
export function startProxy(o: { hostname: string; port: number; allowedHosts?: string; token: string }, view: DevView): DevProxy {
|
|
127
|
+
const traces: TraceEntry[] = []
|
|
128
|
+
let seq = 0
|
|
129
|
+
const allowed = allowedHosts(o.hostname, o.allowedHosts ?? process.env.BRUST_DEV_ALLOWED_HOSTS)
|
|
130
|
+
const sockets = new Set<ServerWebSocket<undefined>>()
|
|
131
|
+
const send = (ws: ServerWebSocket<undefined>, msg: unknown) => {
|
|
132
|
+
try {
|
|
133
|
+
ws.send(JSON.stringify(msg))
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
const hello = () => ({ type: 'hello', gen: view.gen(), state: view.state(), diag: view.state() === 'error' ? view.diag() : null })
|
|
137
|
+
|
|
138
|
+
async function forward(req: Request): Promise<Response> {
|
|
139
|
+
const url = new URL(req.url)
|
|
140
|
+
const gen = view.gen()
|
|
141
|
+
const doc = isDocument(req)
|
|
142
|
+
const port = view.upstream()
|
|
143
|
+
if (port === null) {
|
|
144
|
+
const d = view.diag()
|
|
145
|
+
return htmlPage(503, d ? `brust dev: ${d.rule}` : 'brust dev: building…', d?.raw ?? 'The first build is running. This page reloads when it is ready.', gen)
|
|
146
|
+
}
|
|
147
|
+
const headers = new Headers(req.headers)
|
|
148
|
+
// The token only this proxy knows: whatever a browser sent under that name is overwritten.
|
|
149
|
+
headers.set('x-brust-dev-token', o.token)
|
|
150
|
+
const t0 = performance.now()
|
|
151
|
+
// Injection needs an identity body; `Host` and `Origin` are kept as the browser sent them (the actions cross-site guard reads both).
|
|
152
|
+
headers.set('accept-encoding', 'identity')
|
|
153
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.body !== null
|
|
154
|
+
let res: Response
|
|
155
|
+
try {
|
|
156
|
+
res = await fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, {
|
|
157
|
+
method: req.method,
|
|
158
|
+
headers,
|
|
159
|
+
...(hasBody ? { body: req.body, duplex: 'half' } : {}),
|
|
160
|
+
redirect: 'manual',
|
|
161
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun's fetch option
|
|
162
|
+
} as any)
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return htmlPage(502, 'brust dev: the server is not reachable', String((e as Error).message ?? e), gen)
|
|
165
|
+
}
|
|
166
|
+
const out = new Headers(res.headers)
|
|
167
|
+
// The trace never reaches the browser as a header: it travels in the page (`#__brust_trace`) and `/_brust/dev/traces`.
|
|
168
|
+
const trace = decodeTrace(res.headers.get('x-brust-dev'))
|
|
169
|
+
out.delete('x-brust-dev')
|
|
170
|
+
const isAction = req.method !== 'GET' && req.method !== 'HEAD' ? true : url.pathname.startsWith('/_brust/action')
|
|
171
|
+
if (trace || isAction) {
|
|
172
|
+
traces.push({ id: ++seq, at: Date.now(), kind: trace ? 'page' : 'action', method: req.method, path: url.pathname + url.search, status: res.status, ms: Math.round(performance.now() - t0), trace })
|
|
173
|
+
if (traces.length > MAX_TRACES) traces.shift()
|
|
174
|
+
}
|
|
175
|
+
const tag = (r: Response) => (traceOf.set(r, trace), r)
|
|
176
|
+
out.set('cache-control', 'no-store')
|
|
177
|
+
out.delete('content-length')
|
|
178
|
+
out.delete('content-encoding')
|
|
179
|
+
const loc = out.get('location')
|
|
180
|
+
if (loc) out.set('location', loc.replace(`http://127.0.0.1:${port}`, url.origin))
|
|
181
|
+
const type = res.headers.get('content-type') ?? ''
|
|
182
|
+
if (req.method === 'HEAD' || res.status === 204 || res.status === 304) return new Response(null, { status: res.status, headers: out })
|
|
183
|
+
if (/text\/html/i.test(type)) {
|
|
184
|
+
out.set('content-type', type)
|
|
185
|
+
return tag(new Response(inject(await res.text(), gen, trace), { status: res.status, headers: out }))
|
|
186
|
+
}
|
|
187
|
+
if (doc && res.status >= 500) return tag(htmlPage(res.status, `${res.status} ${res.statusText || 'server error'}`, await res.text(), gen, trace))
|
|
188
|
+
return tag(new Response(res.body, { status: res.status, headers: out }))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const server = Bun.serve<undefined>({
|
|
192
|
+
hostname: o.hostname,
|
|
193
|
+
port: o.port,
|
|
194
|
+
async fetch(req, srv) {
|
|
195
|
+
const url = new URL(req.url)
|
|
196
|
+
// DNS rebinding: a page on evil.example whose name resolves to 127.0.0.1 would otherwise read the app and the
|
|
197
|
+
// source paths in build errors. Only the dev server's own names are answered.
|
|
198
|
+
if (!allowed.has(hostName(req.headers.get('host') ?? ''))) return new Response('brust dev: unexpected Host header (set BRUST_DEV_ALLOWED_HOSTS to allow it)', { status: 421 })
|
|
199
|
+
if (url.pathname === WS_PATH && !sameOriginOrNone(req.headers.get('origin'), req.headers.get('host'))) return new Response('brust dev: cross-origin WebSocket refused', { status: 403 })
|
|
200
|
+
if (url.pathname === WS_PATH) return srv.upgrade(req, { data: undefined }) ? undefined : new Response('websocket expected', { status: 426 })
|
|
201
|
+
if (url.pathname === TRACES_PATH) return Response.json({ traces }, { headers: { 'cache-control': 'no-store' } })
|
|
202
|
+
if (url.pathname === META_PATH) return Response.json(view.meta() ?? {}, { headers: { 'cache-control': 'no-store' } })
|
|
203
|
+
if (url.pathname === DEVTOOLS_PATH) {
|
|
204
|
+
return devtoolsBundle().then(
|
|
205
|
+
(js) => new Response(js, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } }),
|
|
206
|
+
(e: Error) => new Response(e.message, { status: 500 }),
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
if (url.pathname === CLIENT_PATH) return new Response(DEV_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } })
|
|
210
|
+
const t0 = performance.now()
|
|
211
|
+
const res = await forward(req)
|
|
212
|
+
view.onRequest({ method: req.method, path: url.pathname + url.search, status: res.status, ms: Math.round(performance.now() - t0), cache: res.headers.get('x-brust-cache'), trace: traceOf.get(res) ?? null })
|
|
213
|
+
return res
|
|
214
|
+
},
|
|
215
|
+
websocket: {
|
|
216
|
+
open(ws) {
|
|
217
|
+
sockets.add(ws)
|
|
218
|
+
send(ws, hello())
|
|
219
|
+
},
|
|
220
|
+
close(ws) {
|
|
221
|
+
sockets.delete(ws)
|
|
222
|
+
},
|
|
223
|
+
message() {},
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
return {
|
|
227
|
+
server,
|
|
228
|
+
port: server.port ?? o.port,
|
|
229
|
+
broadcast(msg) {
|
|
230
|
+
for (const ws of sockets) send(ws, msg)
|
|
231
|
+
},
|
|
232
|
+
traces: () => traces,
|
|
233
|
+
clients: () => sockets.size,
|
|
234
|
+
stop: () => void server.stop(true),
|
|
235
|
+
}
|
|
236
|
+
}
|