@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.5
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 +64 -6
- package/native/index.d.ts +39 -4
- package/native/index.js +56 -54
- package/package.json +17 -10
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +24 -2
- package/src/actions-bridge.ts +2 -2
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +199 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +39 -13
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +7 -3
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +14 -9
- package/src/build/compile.ts +41 -4
- package/src/build/config.ts +46 -10
- package/src/build/css.ts +286 -0
- package/src/build/index.ts +135 -20
- package/src/build/manifest.ts +23 -4
- package/src/build/scan.ts +59 -20
- 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-time.ts +10 -0
- package/src/cf.ts +2 -1
- package/src/cli.ts +19 -4
- package/src/config.ts +111 -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 +11 -28
- package/src/handlers.ts +41 -2
- package/src/index.ts +3 -1
- package/src/native.ts +18 -0
- package/src/routes.ts +20 -1
- package/src/run.ts +40 -8
- package/src/worker.ts +88 -20
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
|
+
}
|
package/src/dev/tui.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The inline terminal UI of `brust dev` (R13.5). NEVER the alternate screen: request log lines scroll
|
|
2
|
+
// normally, and a status block of at most 6 lines is redrawn at the bottom (cursor-up + clear, wrapped
|
|
3
|
+
// lines counted, resize aware). Not a TTY, `CI` or `NO_COLOR` -> plain line logs, no redraw, no raw mode.
|
|
4
|
+
|
|
5
|
+
export type DevState = 'building' | 'ready' | 'error'
|
|
6
|
+
|
|
7
|
+
export interface TuiStatus {
|
|
8
|
+
state: DevState
|
|
9
|
+
url: string
|
|
10
|
+
gen: number
|
|
11
|
+
buildMs?: number
|
|
12
|
+
/** The last page request (route + status) until L14's per-page trace lands. */
|
|
13
|
+
last?: { method: string; path: string; status: number; /** Components of the last page by tier (from its trace + `dev.json`). */ tiers?: { static: number; native: number; react: number } }
|
|
14
|
+
warnings: string[]
|
|
15
|
+
/** One line shown under the state while `error` (the first line of the diagnostic). */
|
|
16
|
+
error?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TuiOut {
|
|
20
|
+
write(s: string): unknown
|
|
21
|
+
columns?: number
|
|
22
|
+
isTTY?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATE_DOT: Record<DevState, string> = { building: '◌', ready: '●', error: '✖' }
|
|
26
|
+
const STATE_COLOR: Record<DevState, string> = { building: '33', ready: '32', error: '31' }
|
|
27
|
+
export const HOTKEYS = '[r]eload [o]pen [w]arnings [c]lear [q]uit'
|
|
28
|
+
export const MAX_STATUS_LINES = 6
|
|
29
|
+
|
|
30
|
+
/** Visible length (ANSI escapes do not count). */
|
|
31
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escapes
|
|
32
|
+
const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g
|
|
33
|
+
export const visibleLen = (s: string): number => [...s.replace(ANSI, '')].length
|
|
34
|
+
|
|
35
|
+
/** `s` cut to `width` visible columns, with a trailing `…` when it was cut (escapes are not split: input is plain here). */
|
|
36
|
+
function fit(s: string, width: number): string {
|
|
37
|
+
const cs = [...s]
|
|
38
|
+
return cs.length <= width ? s : `${cs.slice(0, Math.max(0, width - 1)).join('')}…`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The status block, plain text (colour is added by the caller), every line at most `width` columns. */
|
|
42
|
+
export function renderStatus(s: TuiStatus, width: number): string[] {
|
|
43
|
+
const w = Math.max(20, width - 1)
|
|
44
|
+
const head = `${STATE_DOT[s.state]} ${s.state} ${s.url} gen ${s.gen}${s.buildMs !== undefined ? ` · built in ${s.buildMs}ms` : ''}`
|
|
45
|
+
const lines = [head]
|
|
46
|
+
if (s.state === 'error' && s.error) lines.push(` error: ${s.error}`)
|
|
47
|
+
else if (s.last) lines.push(` last: ${s.last.method} ${s.last.path} → ${s.last.status}${s.last.tiers ? ` · ${s.last.tiers.static} static ${s.last.tiers.native} native ${s.last.tiers.react} react` : ''}`)
|
|
48
|
+
else lines.push(' last: (no request yet)')
|
|
49
|
+
if (s.warnings.length > 0) lines.push(` warnings: ${s.warnings.length} — ${s.warnings[0]}`)
|
|
50
|
+
lines.push(` ${HOTKEYS}`)
|
|
51
|
+
return lines.slice(0, MAX_STATUS_LINES).map((l) => fit(l, w))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Rows `lines` occupy on a terminal `width` columns wide (a line wider than the terminal wraps). */
|
|
55
|
+
export function rowsFor(lines: string[], width: number): number {
|
|
56
|
+
const w = Math.max(1, width)
|
|
57
|
+
return lines.reduce((n, l) => n + Math.max(1, Math.ceil(visibleLen(l) / w)), 0)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Colour is on for a TTY unless NO_COLOR; the redraw is on only for an interactive TTY outside CI. */
|
|
61
|
+
export function tuiMode(out: TuiOut, env: Record<string, string | undefined>): { interactive: boolean; color: boolean } {
|
|
62
|
+
const tty = out.isTTY === true
|
|
63
|
+
const interactive = tty && !env.CI && !env.NO_COLOR
|
|
64
|
+
return { interactive, color: interactive }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class Tui {
|
|
68
|
+
private status: TuiStatus | null = null
|
|
69
|
+
private drawn: string[] = []
|
|
70
|
+
private closed = false
|
|
71
|
+
private lastPlain = ''
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly out: TuiOut,
|
|
75
|
+
private readonly interactive: boolean,
|
|
76
|
+
private readonly color = interactive,
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
private width(): number {
|
|
80
|
+
return this.out.columns && this.out.columns > 0 ? this.out.columns : 80
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private paint(l: string, i: number): string {
|
|
84
|
+
if (!this.color || !this.status) return l
|
|
85
|
+
if (i === 0) return `\x1b[${STATE_COLOR[this.status.state]}m${l}\x1b[0m`
|
|
86
|
+
return `\x1b[2m${l}\x1b[0m`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private erase(): string {
|
|
90
|
+
if (this.drawn.length === 0) return ''
|
|
91
|
+
const rows = rowsFor(this.drawn, this.width())
|
|
92
|
+
this.drawn = []
|
|
93
|
+
return `\r\x1b[${rows}A\x1b[J`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private draw(): string {
|
|
97
|
+
if (!this.status) return ''
|
|
98
|
+
const lines = renderStatus(this.status, this.width())
|
|
99
|
+
this.drawn = lines
|
|
100
|
+
return `${lines.map((l, i) => this.paint(l, i)).join('\n')}\n`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One scrolling log line (a request, a warning, a message). */
|
|
104
|
+
log(raw: string): void {
|
|
105
|
+
if (this.closed) return
|
|
106
|
+
// Colour only where colour is on: a child's ANSI-coloured tracing line is plain text in plain mode.
|
|
107
|
+
const line = this.color ? raw : raw.replace(ANSI, '')
|
|
108
|
+
if (!this.interactive) {
|
|
109
|
+
this.out.write(`${line}\n`)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
this.out.write(`${this.erase()}${line}\n${this.draw()}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Replace the status block. Plain mode prints a line only when the state line changes. */
|
|
116
|
+
setStatus(s: TuiStatus): void {
|
|
117
|
+
if (this.closed) return
|
|
118
|
+
this.status = s
|
|
119
|
+
if (!this.interactive) {
|
|
120
|
+
const l = `[brust] ${s.state} ${s.url} gen ${s.gen}${s.buildMs !== undefined ? ` (${s.buildMs}ms)` : ''}${s.error ? ` — ${s.error}` : ''}`
|
|
121
|
+
if (l !== this.lastPlain) this.out.write(`${l}\n`)
|
|
122
|
+
this.lastPlain = l
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
this.out.write(`${this.erase()}${this.draw()}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Redraw after a terminal resize (the rows the old block took are recounted at the new width). */
|
|
129
|
+
resize(): void {
|
|
130
|
+
if (this.interactive && !this.closed) this.out.write(`${this.erase()}${this.draw()}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The `c` hotkey: clear the screen and redraw the block. */
|
|
134
|
+
clear(): void {
|
|
135
|
+
if (!this.interactive) return
|
|
136
|
+
this.drawn = []
|
|
137
|
+
this.out.write(`\x1b[2J\x1b[H${this.draw()}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Leave the final block on screen and stop drawing. */
|
|
141
|
+
close(): void {
|
|
142
|
+
this.closed = true
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/dev/watch.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The shared watch/build loop of `brust dev` (bun and cloudflare targets): debounced file events,
|
|
2
|
+
// ONE run at a time, and every change that arrives while a run is in flight is kept for the next
|
|
3
|
+
// run (post-mortem 8f29c26: an edit during a rebuild was dropped). `run` receives the app-relative
|
|
4
|
+
// paths that changed since the previous run started.
|
|
5
|
+
import { type FSWatcher, statSync, watch } from 'node:fs'
|
|
6
|
+
import { join, sep } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export interface WatchLoop {
|
|
9
|
+
/** Queue a run as if `rel` changed (the `r` hotkey, tests). */
|
|
10
|
+
trigger(rel?: string): void
|
|
11
|
+
/** Stop watching and drop queued changes; an in-flight run is left to finish. */
|
|
12
|
+
close(): void
|
|
13
|
+
/** Resolves when no run is in flight and nothing is queued. */
|
|
14
|
+
idle(): Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function watchLoop(o: { root: string; ignored: (rel: string) => boolean; debounceMs: number; run: (changed: Set<string>) => Promise<void> }): WatchLoop {
|
|
18
|
+
let pending = new Set<string>()
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
20
|
+
let running = false
|
|
21
|
+
let closed = false
|
|
22
|
+
let idleWaiters: (() => void)[] = []
|
|
23
|
+
const pump = async () => {
|
|
24
|
+
if (running) return // the running loop picks `pending` up when it finishes
|
|
25
|
+
running = true
|
|
26
|
+
try {
|
|
27
|
+
while (pending.size > 0 && !closed) {
|
|
28
|
+
const batch = pending
|
|
29
|
+
pending = new Set()
|
|
30
|
+
try {
|
|
31
|
+
await o.run(batch)
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(`[brust] dev run failed: ${(e as Error).stack ?? String(e)}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
running = false
|
|
38
|
+
if (pending.size === 0) for (const w of idleWaiters.splice(0)) w()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const queue = (rel: string) => {
|
|
42
|
+
if (closed) return
|
|
43
|
+
pending.add(rel)
|
|
44
|
+
clearTimeout(timer)
|
|
45
|
+
timer = setTimeout(() => void pump(), o.debounceMs)
|
|
46
|
+
}
|
|
47
|
+
const watcher: FSWatcher = watch(o.root, { recursive: true }, (_ev, name) => {
|
|
48
|
+
if (!name) return
|
|
49
|
+
const rel = String(name).split(sep).join('/')
|
|
50
|
+
if (o.ignored(rel)) return
|
|
51
|
+
// A directory's own event is metadata noise (reading a dir bumps its atime, which FSEvents reports): a real
|
|
52
|
+
// change inside it reports the file itself. A path that no longer exists is a delete, which counts.
|
|
53
|
+
try {
|
|
54
|
+
if (statSync(join(o.root, rel)).isDirectory()) return
|
|
55
|
+
} catch {}
|
|
56
|
+
queue(rel)
|
|
57
|
+
})
|
|
58
|
+
return {
|
|
59
|
+
trigger: (rel = '.') => queue(rel),
|
|
60
|
+
close() {
|
|
61
|
+
closed = true
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
pending.clear()
|
|
64
|
+
watcher.close()
|
|
65
|
+
for (const w of idleWaiters.splice(0)) w()
|
|
66
|
+
},
|
|
67
|
+
idle: () => (!running && pending.size === 0 ? Promise.resolve() : new Promise<void>((r) => idleWaiters.push(r))),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.brust'])
|
|
72
|
+
const SKIP_FILE = /(^|\/)(\.DS_Store|4913)$|~$|\.(swp|swx|tmp|log)$|\.(sqlite|db)(-wal|-shm|-journal)?$/
|
|
73
|
+
|
|
74
|
+
/** Paths a dev session never rebuilds for: dependency/VCS dirs, brust's own output, editor temp files, databases. */
|
|
75
|
+
export function defaultIgnored(rel: string, outDirRel: string): boolean {
|
|
76
|
+
const segs = rel.split('/')
|
|
77
|
+
if (segs.some((s) => SKIP_DIRS.has(s))) return true
|
|
78
|
+
const out = outDirRel.replace(/\/+$/, '')
|
|
79
|
+
if (rel === out || rel.startsWith(`${out}/`) || rel.startsWith(`${out}.`) || rel.startsWith(`.${out}.brust-`)) return true
|
|
80
|
+
return SKIP_FILE.test(rel)
|
|
81
|
+
}
|
package/src/dev.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// `brust dev [entry] --target cloudflare [-- <wrangler args>]` (M4-CF R10): build, run `wrangler dev` on the
|
|
2
2
|
// generated project, rebuild + restart wrangler on a source change. No HMR. Each build runs in a child
|
|
3
3
|
// process: a failed build keeps the old wrangler, and module caches never leak between builds.
|
|
4
|
-
import {
|
|
5
|
-
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { join, relative, resolve } from 'node:path'
|
|
6
5
|
import { BuildError } from './build/errors'
|
|
6
|
+
import { defaultIgnored, type WatchLoop, watchLoop } from './dev/watch'
|
|
7
7
|
|
|
8
8
|
export interface DevOptions {
|
|
9
9
|
appRoot: string
|
|
@@ -15,7 +15,6 @@ export interface DevOptions {
|
|
|
15
15
|
log: (s: string) => void
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const IGNORED = new Set(['node_modules', '.wrangler', '.git'])
|
|
19
18
|
const DEBOUNCE_MS = 300
|
|
20
19
|
|
|
21
20
|
function wranglerBin(appRoot: string): string {
|
|
@@ -54,7 +53,7 @@ export async function runDev(o: DevOptions): Promise<number> {
|
|
|
54
53
|
child = c
|
|
55
54
|
c.exited.then((code) => {
|
|
56
55
|
if (child !== c || stopping || restarting) return
|
|
57
|
-
|
|
56
|
+
loop.close()
|
|
58
57
|
exitCode(code)
|
|
59
58
|
})
|
|
60
59
|
}
|
|
@@ -65,17 +64,12 @@ export async function runDev(o: DevOptions): Promise<number> {
|
|
|
65
64
|
await c.exited
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
building = true
|
|
77
|
-
do {
|
|
78
|
-
again = false
|
|
67
|
+
// The shared watch/build loop (dev/watch.ts): one rebuild at a time, edits during a rebuild are kept for the next.
|
|
68
|
+
const loop: WatchLoop = watchLoop({
|
|
69
|
+
root: appRoot,
|
|
70
|
+
ignored: (rel) => defaultIgnored(rel, relative(appRoot, dist)),
|
|
71
|
+
debounceMs: DEBOUNCE_MS,
|
|
72
|
+
async run() {
|
|
79
73
|
o.log('[brust] change detected; rebuilding')
|
|
80
74
|
if ((await build()) === 0) {
|
|
81
75
|
restarting = true
|
|
@@ -83,25 +77,14 @@ export async function runDev(o: DevOptions): Promise<number> {
|
|
|
83
77
|
restarting = false
|
|
84
78
|
if (!stopping) spawn()
|
|
85
79
|
} else o.log('[brust] rebuild failed; keeping the running wrangler')
|
|
86
|
-
}
|
|
87
|
-
building = false
|
|
88
|
-
}
|
|
89
|
-
const ignored = (rel: string) => {
|
|
90
|
-
const abs = resolve(appRoot, rel)
|
|
91
|
-
return rel.split(sep).some((s) => IGNORED.has(s)) || abs === dist || abs.startsWith(dist + sep) || abs.startsWith(`${dist}.`) || abs.startsWith(join(dirname(dist), `.${basename(dist)}.brust-`)) || relative(dist, abs) === ''
|
|
92
|
-
}
|
|
93
|
-
let watcher: FSWatcher | null = watch(appRoot, { recursive: true }, (_ev, name) => {
|
|
94
|
-
if (!name || ignored(String(name))) return
|
|
95
|
-
clearTimeout(timer)
|
|
96
|
-
timer = setTimeout(() => void rebuild(), DEBOUNCE_MS)
|
|
80
|
+
},
|
|
97
81
|
})
|
|
98
82
|
|
|
99
83
|
spawn()
|
|
100
84
|
const stop = () => {
|
|
101
85
|
if (stopping) return
|
|
102
86
|
stopping = true
|
|
103
|
-
|
|
104
|
-
watcher?.close()
|
|
87
|
+
loop.close()
|
|
105
88
|
void stopChild().then(() => exitCode(0))
|
|
106
89
|
}
|
|
107
90
|
process.on('SIGINT', stop)
|
package/src/handlers.ts
CHANGED
|
@@ -141,6 +141,39 @@ function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderRespons
|
|
|
141
141
|
return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/** What a loader did during a static render (gaps round R9.5) that a static file cannot honour. */
|
|
145
|
+
export type SsgRecord =
|
|
146
|
+
| { kind: 'read'; routeId: string; path: string; field: 'headers' | 'cookies' | 'search'; key: string }
|
|
147
|
+
| { kind: 'headers'; routeId: string; path: string; names: string[] }
|
|
148
|
+
|
|
149
|
+
/** R9.5: the request a loader sees while rendering static output: a visitor with no headers, cookies
|
|
150
|
+
* or query. Every read of `headers` / `cookies` / `search` (a key, `in`, or a listing: key `*`) is
|
|
151
|
+
* recorded, so the build can warn that the page written ignores it. */
|
|
152
|
+
export function ssgRequest(req: LoaderReq, routeId: string, path: string, record: (r: SsgRecord) => void): LoaderReq {
|
|
153
|
+
const watched = (field: 'headers' | 'cookies' | 'search') => {
|
|
154
|
+
const read = (key: string) => record({ kind: 'read', routeId, path, field, key })
|
|
155
|
+
return new Proxy(Object.create(null) as Record<string, string>, {
|
|
156
|
+
get: (t, k) => {
|
|
157
|
+
if (typeof k === 'string') read(k)
|
|
158
|
+
return Reflect.get(t, k)
|
|
159
|
+
},
|
|
160
|
+
has: (t, k) => {
|
|
161
|
+
if (typeof k === 'string') read(k)
|
|
162
|
+
return Reflect.has(t, k)
|
|
163
|
+
},
|
|
164
|
+
ownKeys: (t) => {
|
|
165
|
+
read('*')
|
|
166
|
+
return Reflect.ownKeys(t)
|
|
167
|
+
},
|
|
168
|
+
getOwnPropertyDescriptor: (t, k) => {
|
|
169
|
+
if (typeof k === 'string') read(k)
|
|
170
|
+
return Reflect.getOwnPropertyDescriptor(t, k)
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
return { method: 'GET', url: req.url, headers: watched('headers'), cookies: watched('cookies'), search: watched('search') }
|
|
175
|
+
}
|
|
176
|
+
|
|
144
177
|
export interface Handlers {
|
|
145
178
|
loader(req: LoaderRequest): Promise<LoaderResponse>
|
|
146
179
|
jobs(req: JobsRequest): Promise<JobsResponse>
|
|
@@ -154,6 +187,8 @@ export function makeHandlers(opts: {
|
|
|
154
187
|
manifest?: ManifestLike
|
|
155
188
|
/** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
|
|
156
189
|
stores?: Record<string, object>
|
|
190
|
+
/** Static output (R9.5): loaders see `ssgRequest`, and what a static file drops is recorded. */
|
|
191
|
+
ssg?: (r: SsgRecord) => void
|
|
157
192
|
}): Handlers {
|
|
158
193
|
const byId = new Map(opts.leaves.map((l) => [l.id, l]))
|
|
159
194
|
const literals = literalsIndex(opts.manifest)
|
|
@@ -206,7 +241,8 @@ export function makeHandlers(opts: {
|
|
|
206
241
|
if (!node.loader) continue
|
|
207
242
|
let r: unknown
|
|
208
243
|
try {
|
|
209
|
-
|
|
244
|
+
const lreq = opts.ssg ? ssgRequest(req.req, req.routeId, req.path, opts.ssg) : req.req
|
|
245
|
+
r = await node.loader({ params: req.params, path: req.path, req: lreq })
|
|
210
246
|
} catch (e) {
|
|
211
247
|
if (isHttpErrorTrigger(e)) return { verdict: 'httpError', status: e.status, body: e.body } as LoaderResponse
|
|
212
248
|
throw e
|
|
@@ -217,7 +253,10 @@ export function makeHandlers(opts: {
|
|
|
217
253
|
}
|
|
218
254
|
if (r && typeof r === 'object') Object.assign(merged, r)
|
|
219
255
|
}
|
|
220
|
-
|
|
256
|
+
const ok = withStore({ ok: true, data: merged } as LoaderResponse)
|
|
257
|
+
const headers = (ok as { headers?: Record<string, string> }).headers
|
|
258
|
+
if (opts.ssg && headers && Object.keys(headers).length > 0) opts.ssg({ kind: 'headers', routeId: req.routeId, path: req.path, names: Object.keys(headers) })
|
|
259
|
+
return ok
|
|
221
260
|
} catch (e) {
|
|
222
261
|
return { error: String(e) } as LoaderResponse
|
|
223
262
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
/// <reference path="./css-modules.d.ts" />
|
|
1
2
|
// `@brust/core`: the app-facing API.
|
|
3
|
+
export { buildTime } from './build-time'
|
|
2
4
|
export { cache } from './cache'
|
|
3
5
|
export { type BrustConfig, BrustConfigError, loadConfig } from './config'
|
|
4
6
|
export { type RunOptions, run } from './run'
|
|
@@ -15,4 +17,4 @@ export {
|
|
|
15
17
|
Outlet,
|
|
16
18
|
redirect,
|
|
17
19
|
} from './routes'
|
|
18
|
-
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
|
|
20
|
+
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, RouteSsgConfig, Verdict } from './routes'
|