@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.4
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 +65 -2
- package/native/index.js +59 -54
- package/package.json +16 -8
- 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 +29 -11
- 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 +48 -12
- 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 +315 -0
- package/src/build/compile.ts +41 -4
- package/src/build/config.ts +255 -10
- package/src/build/css.ts +286 -0
- package/src/build/index.ts +199 -16
- package/src/build/manifest.ts +41 -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/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 +50 -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 +38 -1
- package/src/routes.ts +20 -1
- package/src/run.ts +46 -9
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +125 -245
package/src/config.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// Runtime configuration (spec §8). Precedence, high → low:
|
|
2
2
|
// env (BRUST_ADDR / BRUST_PORT / BRUST_WORKERS / BRUST_RENDER_SLOTS / BRUST_DRAIN_TIMEOUT_MS /
|
|
3
|
-
// BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS)
|
|
4
|
-
// > CLI flags (`brust start --port/--workers`)
|
|
5
|
-
// > brust.toml ([server] address / port, [workers] count)
|
|
6
|
-
// > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000).
|
|
3
|
+
// BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES)
|
|
4
|
+
// > CLI flags (`brust start --port/--workers`) and `run({ config })`
|
|
5
|
+
// > brust.toml ([server] address / port / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
|
|
6
|
+
// > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000, 262144, 8388608).
|
|
7
|
+
// L16 R16.1: `actionsBaseUrl` follows env (BRUST_ACTIONS_BASE_URL) > run({ config }) > toml `[actions] base_url`.
|
|
8
|
+
// `cors` has no env layer; `run({ config: { cors } })` replaces a toml `[cors]` as a whole (never merged), and
|
|
9
|
+
// `cors: null` turns a toml `[cors]` off.
|
|
7
10
|
// `brust start`'s BRUST_DIST_DIR / BRUST_APP_ENTRY follow the same env > flag rule (run.ts).
|
|
8
11
|
// A missing brust.toml is fine; a present one with the wrong shape is an error. Messages follow
|
|
9
12
|
// 0.1.x `runtime/config.ts`.
|
|
10
13
|
import { availableParallelism } from 'node:os'
|
|
11
14
|
import { join } from 'node:path'
|
|
15
|
+
import { ACTION_CREDENTIALS, type ActionCredentials, baseUrlError } from './actions/prefix'
|
|
12
16
|
|
|
13
17
|
/** `[cors]` in brust.toml; same shape and validation as the Rust `CorsConfig` (crates/brust-server/src/config.rs). */
|
|
14
18
|
export interface CorsConfig {
|
|
@@ -32,10 +36,22 @@ export interface BrustConfig {
|
|
|
32
36
|
callTimeoutMs: number
|
|
33
37
|
/** tokio threads of the HTTP runtime (renders every page); absent = one per core (server default). */
|
|
34
38
|
ioThreads?: number
|
|
35
|
-
/**
|
|
39
|
+
/** The SAB response slot of one render slot, bytes (`slot_bytes`, env BRUST_SLOT_BYTES); 64 KiB..16 MiB. */
|
|
40
|
+
slotBytes: number
|
|
41
|
+
/** The biggest loader/jobs reply that crosses by heap when it does not fit the slot (`max_loader_response_bytes`,
|
|
42
|
+
* env BRUST_MAX_LOADER_RESPONSE_BYTES); 256 KiB..64 MiB. */
|
|
43
|
+
maxLoaderResponseBytes: number
|
|
44
|
+
/** `run({ config: { cors } })` over brust.toml `[cors]` (a whole-object replace); absent = CORS off. No env layer. */
|
|
36
45
|
cors?: CorsConfig
|
|
46
|
+
/** L16 R16.1: the origin the browser `client()` calls (page script `__BRUST_ACTION_BASE__`); absent = same origin. */
|
|
47
|
+
actionsBaseUrl?: string
|
|
48
|
+
/** L16 R16.2: the client's default `RequestInit.credentials` (`__BRUST_ACTION_CREDENTIALS__`); absent = `same-origin`. */
|
|
49
|
+
actionsCredentials?: ActionCredentials
|
|
37
50
|
}
|
|
38
51
|
|
|
52
|
+
/** What `run({ config })` / `loadConfig`'s `cli` accepts: any setting, and `cors: null` to turn a toml `[cors]` off. */
|
|
53
|
+
export type ConfigOverrides = Omit<Partial<BrustConfig>, 'cors'> & { cors?: (Omit<CorsConfig, 'credentials'> & { credentials?: boolean }) | null }
|
|
54
|
+
|
|
39
55
|
export class BrustConfigError extends Error {
|
|
40
56
|
override name = 'BrustConfigError'
|
|
41
57
|
constructor(
|
|
@@ -46,11 +62,11 @@ export class BrustConfigError extends Error {
|
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
|
|
49
|
-
const DEFAULTS = { host: 'localhost', port: 1337, drainTimeoutMs: 10_000, bootTimeoutMs: 30_000, callTimeoutMs: 30_000 }
|
|
65
|
+
const DEFAULTS = { host: 'localhost', port: 1337, drainTimeoutMs: 10_000, bootTimeoutMs: 30_000, callTimeoutMs: 30_000, slotBytes: 256 * 1024, maxLoaderResponseBytes: 8_388_608 }
|
|
50
66
|
|
|
51
67
|
/** Ceilings: ms values cross napi as `u32` (a larger number would wrap), more than 1024 Bun
|
|
52
|
-
* workers is a misconfiguration, not a deployment, and each render slot is a 256 KiB
|
|
53
|
-
* response buffer per worker (more than 64 would only exhaust memory). */
|
|
68
|
+
* workers is a misconfiguration, not a deployment, and each render slot is a `slotBytes` (default 256 KiB)
|
|
69
|
+
* shared response buffer per worker (more than 64 would only exhaust memory). */
|
|
54
70
|
export const MAX_MS = 0xffff_ffff
|
|
55
71
|
/** `max_age_seconds` crosses napi as `u32`, like the ms values. */
|
|
56
72
|
const MAX_U32 = 0xffff_ffff
|
|
@@ -58,8 +74,14 @@ const MAX_U32 = 0xffff_ffff
|
|
|
58
74
|
export const MAX_IO_THREADS = 256
|
|
59
75
|
export const MAX_WORKERS = 1024
|
|
60
76
|
export const MAX_RENDER_SLOTS = 64
|
|
77
|
+
/** `slotBytes` (L10): 64 KiB..16 MiB. */
|
|
78
|
+
export const MIN_SLOT_BYTES = 64 * 1024
|
|
79
|
+
export const MAX_SLOT_BYTES = 16 * 1024 * 1024
|
|
80
|
+
/** `maxLoaderResponseBytes` (L10): 256 KiB..64 MiB, like the actions limits. */
|
|
81
|
+
export const MIN_LOADER_RESPONSE_BYTES = 256 * 1024
|
|
82
|
+
export const MAX_LOADER_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
61
83
|
|
|
62
|
-
export async function loadConfig(cwd: string = process.cwd(), cli:
|
|
84
|
+
export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrides = {}): Promise<BrustConfig> {
|
|
63
85
|
const tomlPath = join(cwd, 'brust.toml')
|
|
64
86
|
let fromToml: Partial<BrustConfig> = {}
|
|
65
87
|
const file = Bun.file(tomlPath)
|
|
@@ -75,6 +97,8 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
|
|
|
75
97
|
}
|
|
76
98
|
const env = fromEnv()
|
|
77
99
|
checkCli(cli)
|
|
100
|
+
// cli (validated, normalised) over toml as a whole object (R10.2); `null` = off.
|
|
101
|
+
const cors = cli.cors === undefined ? fromToml.cors : cli.cors === null ? undefined : checkCors(cli.cors, 'config.cors', null, false)
|
|
78
102
|
return {
|
|
79
103
|
host: env.host ?? cli.host ?? fromToml.host ?? DEFAULTS.host,
|
|
80
104
|
port: env.port ?? cli.port ?? fromToml.port ?? DEFAULTS.port,
|
|
@@ -84,13 +108,35 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
|
|
|
84
108
|
drainTimeoutMs: env.drainTimeoutMs ?? cli.drainTimeoutMs ?? DEFAULTS.drainTimeoutMs,
|
|
85
109
|
bootTimeoutMs: env.bootTimeoutMs ?? cli.bootTimeoutMs ?? DEFAULTS.bootTimeoutMs,
|
|
86
110
|
callTimeoutMs: env.callTimeoutMs ?? cli.callTimeoutMs ?? DEFAULTS.callTimeoutMs,
|
|
111
|
+
slotBytes: env.slotBytes ?? cli.slotBytes ?? fromToml.slotBytes ?? DEFAULTS.slotBytes,
|
|
112
|
+
maxLoaderResponseBytes: env.maxLoaderResponseBytes ?? cli.maxLoaderResponseBytes ?? fromToml.maxLoaderResponseBytes ?? DEFAULTS.maxLoaderResponseBytes,
|
|
87
113
|
ioThreads: env.ioThreads ?? cli.ioThreads,
|
|
88
|
-
...(
|
|
114
|
+
...(cors ? { cors } : {}),
|
|
115
|
+
...pick('actionsBaseUrl', env.actionsBaseUrl ?? cli.actionsBaseUrl ?? fromToml.actionsBaseUrl),
|
|
116
|
+
...pick('actionsCredentials', cli.actionsCredentials ?? fromToml.actionsCredentials),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pick = <K extends string, V>(k: K, v: V | undefined) => (v === undefined ? {} : ({ [k]: v } as { [P in K]: V }))
|
|
121
|
+
|
|
122
|
+
/** `[cors]` of brust.toml and nothing else (no env, no cli): what the Cloudflare build ships in CONFIG.
|
|
123
|
+
* Throws `BrustConfigError` on a bad table, exactly like `loadConfig`. */
|
|
124
|
+
export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
|
|
125
|
+
const tomlPath = join(cwd, 'brust.toml')
|
|
126
|
+
const file = Bun.file(tomlPath)
|
|
127
|
+
if (!(await file.exists())) return undefined
|
|
128
|
+
let parsed: unknown
|
|
129
|
+
try {
|
|
130
|
+
parsed = Bun.TOML.parse(await file.text())
|
|
131
|
+
} catch (e) {
|
|
132
|
+
throw new BrustConfigError(`failed to parse ${tomlPath}: ${(e as Error).message}`, tomlPath)
|
|
89
133
|
}
|
|
134
|
+
const root = (parsed ?? {}) as Record<string, unknown>
|
|
135
|
+
return 'cors' in root ? parseCors(root.cors, tomlPath) : undefined
|
|
90
136
|
}
|
|
91
137
|
|
|
92
138
|
/** CLI / programmatic overrides get the same bounds as env (a CLI `--port 0` = any free port). */
|
|
93
|
-
function checkCli(cli:
|
|
139
|
+
function checkCli(cli: ConfigOverrides): void {
|
|
94
140
|
const check = (name: string, v: number | undefined, min: number, max: number) => {
|
|
95
141
|
if (v !== undefined && (!Number.isInteger(v) || v < min || v > max))
|
|
96
142
|
throw new BrustConfigError(`${name} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`, null)
|
|
@@ -102,6 +148,14 @@ function checkCli(cli: Partial<BrustConfig>): void {
|
|
|
102
148
|
check('bootTimeoutMs', cli.bootTimeoutMs, 1, MAX_MS)
|
|
103
149
|
check('callTimeoutMs', cli.callTimeoutMs, 1, MAX_MS)
|
|
104
150
|
check('ioThreads', cli.ioThreads, 1, MAX_IO_THREADS)
|
|
151
|
+
check('slotBytes', cli.slotBytes, MIN_SLOT_BYTES, MAX_SLOT_BYTES)
|
|
152
|
+
check('maxLoaderResponseBytes', cli.maxLoaderResponseBytes, MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES)
|
|
153
|
+
if (cli.actionsBaseUrl !== undefined) {
|
|
154
|
+
const why = baseUrlError(cli.actionsBaseUrl)
|
|
155
|
+
if (why) throw new BrustConfigError(`config.actionsBaseUrl ${why}`, null)
|
|
156
|
+
}
|
|
157
|
+
if (cli.actionsCredentials !== undefined && !ACTION_CREDENTIALS.includes(cli.actionsCredentials))
|
|
158
|
+
throw new BrustConfigError(`config.actionsCredentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(cli.actionsCredentials)})`, null)
|
|
105
159
|
}
|
|
106
160
|
|
|
107
161
|
function table(v: unknown, what: string, file: string): Record<string, unknown> {
|
|
@@ -109,12 +163,21 @@ function table(v: unknown, what: string, file: string): Record<string, unknown>
|
|
|
109
163
|
return v as Record<string, unknown>
|
|
110
164
|
}
|
|
111
165
|
|
|
166
|
+
function tomlInt(v: unknown, name: string, min: number, max: number, file: string): number | undefined {
|
|
167
|
+
if (v === undefined) return undefined
|
|
168
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max)
|
|
169
|
+
throw new BrustConfigError(`${file}: ${name} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`, file)
|
|
170
|
+
return v
|
|
171
|
+
}
|
|
172
|
+
|
|
112
173
|
function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
|
|
113
174
|
const root = table(parsed, 'top level', file)
|
|
114
175
|
const out: Partial<BrustConfig> = {}
|
|
115
176
|
if ('server' in root) {
|
|
116
177
|
const server = table(root.server, '[server]', file)
|
|
117
178
|
const { port, address } = server
|
|
179
|
+
const cap = tomlInt(server.max_loader_response_bytes, 'server.max_loader_response_bytes', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, file)
|
|
180
|
+
if (cap !== undefined) out.maxLoaderResponseBytes = cap
|
|
118
181
|
if (port !== undefined) {
|
|
119
182
|
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
120
183
|
throw new BrustConfigError(`${file}: server.port must be an integer in 1..65535 (got ${JSON.stringify(port)})`, file)
|
|
@@ -127,7 +190,10 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
|
|
|
127
190
|
}
|
|
128
191
|
}
|
|
129
192
|
if ('workers' in root) {
|
|
130
|
-
const
|
|
193
|
+
const w = table(root.workers, '[workers]', file)
|
|
194
|
+
const { count } = w
|
|
195
|
+
const slot = tomlInt(w.slot_bytes, 'workers.slot_bytes', MIN_SLOT_BYTES, MAX_SLOT_BYTES, file)
|
|
196
|
+
if (slot !== undefined) out.slotBytes = slot
|
|
131
197
|
if (count !== undefined) {
|
|
132
198
|
if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > MAX_WORKERS)
|
|
133
199
|
throw new BrustConfigError(`${file}: workers.count must be an integer in 1..${MAX_WORKERS} (got ${JSON.stringify(count)})`, file)
|
|
@@ -135,34 +201,62 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
|
|
|
135
201
|
}
|
|
136
202
|
}
|
|
137
203
|
if ('cors' in root) out.cors = parseCors(root.cors, file)
|
|
204
|
+
if ('actions' in root) {
|
|
205
|
+
const a = table(root.actions, '[actions]', file)
|
|
206
|
+
if (a.base_url !== undefined) {
|
|
207
|
+
const why = baseUrlError(a.base_url)
|
|
208
|
+
if (why) throw new BrustConfigError(`${file}: actions.base_url ${why}`, file)
|
|
209
|
+
out.actionsBaseUrl = a.base_url as string
|
|
210
|
+
}
|
|
211
|
+
if (a.credentials !== undefined) {
|
|
212
|
+
if (typeof a.credentials !== 'string' || !ACTION_CREDENTIALS.includes(a.credentials as ActionCredentials))
|
|
213
|
+
throw new BrustConfigError(`${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(a.credentials)})`, file)
|
|
214
|
+
out.actionsCredentials = a.credentials as ActionCredentials
|
|
215
|
+
}
|
|
216
|
+
}
|
|
138
217
|
return out
|
|
139
218
|
}
|
|
140
219
|
|
|
141
220
|
function parseCors(v: unknown, file: string): CorsConfig {
|
|
142
|
-
|
|
221
|
+
return checkCors(corsFromToml(table(v, 'cors', file)), `${file}: cors`, file, true)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** brust.toml `[cors]` (snake_case keys) → the camelCase shape [`checkCors`] validates. Unknown toml keys are ignored, as always. */
|
|
225
|
+
function corsFromToml(t: Record<string, unknown>): Record<string, unknown> {
|
|
226
|
+
return { origins: t.origins, methods: t.methods, headers: t.headers, exposeHeaders: t.expose_headers, credentials: t.credentials, maxAgeSeconds: t.max_age_seconds }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const CORS_KEYS = ['origins', 'methods', 'headers', 'exposeHeaders', 'credentials', 'maxAgeSeconds']
|
|
230
|
+
|
|
231
|
+
/** The one `[cors]` validator (Rust `CorsConfig::validate`, verbatim), for toml (`where` = `<file>: cors`, key names in
|
|
232
|
+
* snake_case) and for `run({ config: { cors } })` (`where` = `config.cors`, camelCase; an unknown key is an error). */
|
|
233
|
+
function checkCors(c: unknown, where: string, file: string | null, snake: boolean): CorsConfig {
|
|
234
|
+
const fail = (m: string) => new BrustConfigError(m, file)
|
|
235
|
+
if (c === null || typeof c !== 'object' || Array.isArray(c)) throw fail(`${where} must be ${file === null ? 'an object (or null)' : 'a table'} (got ${JSON.stringify(c)})`)
|
|
236
|
+
const t = c as Record<string, unknown>
|
|
237
|
+
if (!snake) {
|
|
238
|
+
const bad = Object.keys(t).find((k) => !CORS_KEYS.includes(k))
|
|
239
|
+
if (bad !== undefined) throw fail(`${where}.${bad} is not a cors option (expected ${CORS_KEYS.join(', ')})`)
|
|
240
|
+
}
|
|
241
|
+
const name = (k: string) => (snake ? k.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`) : k)
|
|
143
242
|
const strings = (val: unknown, key: string): string[] | undefined => {
|
|
144
243
|
if (val === undefined) return undefined
|
|
145
|
-
if (!Array.isArray(val) || val.some((x) => typeof x !== 'string'))
|
|
146
|
-
throw new BrustConfigError(`${file}: cors.${key} must be an array of strings (got ${JSON.stringify(val)})`, file)
|
|
244
|
+
if (!Array.isArray(val) || val.some((x) => typeof x !== 'string')) throw fail(`${where}.${name(key)} must be an array of strings (got ${JSON.stringify(val)})`)
|
|
147
245
|
return val as string[]
|
|
148
246
|
}
|
|
149
247
|
const origins = strings(t.origins, 'origins') ?? []
|
|
150
248
|
const methods = strings(t.methods, 'methods')
|
|
151
249
|
const headers = strings(t.headers, 'headers')
|
|
152
|
-
const exposeHeaders = strings(t.
|
|
153
|
-
if (t.credentials !== undefined && typeof t.credentials !== 'boolean')
|
|
154
|
-
throw new BrustConfigError(`${file}: cors.credentials must be a boolean (got ${JSON.stringify(t.credentials)})`, file)
|
|
250
|
+
const exposeHeaders = strings(t.exposeHeaders, 'exposeHeaders')
|
|
251
|
+
if (t.credentials !== undefined && typeof t.credentials !== 'boolean') throw fail(`${where}.credentials must be a boolean (got ${JSON.stringify(t.credentials)})`)
|
|
155
252
|
const credentials = t.credentials === true
|
|
156
|
-
const age = t.
|
|
253
|
+
const age = t.maxAgeSeconds
|
|
157
254
|
if (age !== undefined && (typeof age !== 'number' || !Number.isInteger(age) || age < 0 || age > MAX_U32))
|
|
158
|
-
throw
|
|
255
|
+
throw fail(`${where}.${name('maxAgeSeconds')} must be an integer in 0..${MAX_U32} (got ${JSON.stringify(age)})`)
|
|
159
256
|
// Rust CorsConfig::validate, verbatim.
|
|
160
|
-
if (origins.length === 0) throw
|
|
257
|
+
if (origins.length === 0) throw fail(`${where}.origins must be non-empty`)
|
|
161
258
|
if (credentials && origins.includes('*'))
|
|
162
|
-
throw
|
|
163
|
-
`${file}: cors.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`,
|
|
164
|
-
file,
|
|
165
|
-
)
|
|
259
|
+
throw fail(`${where}.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`)
|
|
166
260
|
return {
|
|
167
261
|
origins,
|
|
168
262
|
...(methods ? { methods } : {}),
|
|
@@ -195,6 +289,14 @@ function fromEnv(): Partial<BrustConfig> {
|
|
|
195
289
|
out.drainTimeoutMs = envInt('BRUST_DRAIN_TIMEOUT_MS', 0, MAX_MS, `an integer in 0..${MAX_MS}`)
|
|
196
290
|
out.bootTimeoutMs = envInt('BRUST_BOOT_TIMEOUT_MS', 1, MAX_MS, `an integer in 1..${MAX_MS}`)
|
|
197
291
|
out.callTimeoutMs = envInt('BRUST_CALL_TIMEOUT_MS', 1, MAX_MS, `an integer in 1..${MAX_MS}`)
|
|
292
|
+
out.slotBytes = envInt('BRUST_SLOT_BYTES', MIN_SLOT_BYTES, MAX_SLOT_BYTES, `an integer in ${MIN_SLOT_BYTES}..${MAX_SLOT_BYTES}`)
|
|
293
|
+
out.maxLoaderResponseBytes = envInt('BRUST_MAX_LOADER_RESPONSE_BYTES', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, `an integer in ${MIN_LOADER_RESPONSE_BYTES}..${MAX_LOADER_RESPONSE_BYTES}`)
|
|
198
294
|
out.ioThreads = envInt('BRUST_WORKER_THREADS', 1, MAX_IO_THREADS, `an integer in 1..${MAX_IO_THREADS}`)
|
|
295
|
+
const base = process.env.BRUST_ACTIONS_BASE_URL
|
|
296
|
+
if (base !== undefined && base !== '') {
|
|
297
|
+
const why = baseUrlError(base)
|
|
298
|
+
if (why) throw new BrustConfigError(`BRUST_ACTIONS_BASE_URL ${why}`, null)
|
|
299
|
+
out.actionsBaseUrl = base
|
|
300
|
+
}
|
|
199
301
|
return out
|
|
200
302
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// L19 (R19.4): a `.module.css` import is its class map (the build's `cssPlugin`); any other `.css`
|
|
2
|
+
// import is a side-effect import the server links (R19.3). Pulled in by a `/// <reference>` from the
|
|
3
|
+
// entries apps import, so both forms typecheck.
|
|
4
|
+
declare module '*.module.css' {
|
|
5
|
+
const classes: { readonly [name: string]: string }
|
|
6
|
+
export default classes
|
|
7
|
+
}
|
|
8
|
+
declare module '*.css' {}
|
package/src/dev/bun.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// `brust dev` for the Bun target (R13): a long-lived parent owns the port (proxy.ts), every successful
|
|
2
|
+
// build is booted as a NEW child (`brust start` on a loopback port) and swapped in blue/green. A failed
|
|
3
|
+
// build or a failed boot keeps the old child serving. Never an in-process reload: one SERVER per
|
|
4
|
+
// process, workers never restart, SABs are never freed (R13.1).
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { loadConfig } from '../config'
|
|
9
|
+
import { type Diag, parseBuildError, parseWarnings } from './diag'
|
|
10
|
+
import { tierCounts } from './devtools-data'
|
|
11
|
+
import { type DevProxy, startProxy } from './proxy'
|
|
12
|
+
import { HOTKEYS, Tui, type TuiOut, tuiMode } from './tui'
|
|
13
|
+
import { defaultIgnored, watchLoop } from './watch'
|
|
14
|
+
|
|
15
|
+
export interface BunDevOptions {
|
|
16
|
+
appRoot: string
|
|
17
|
+
entry: string
|
|
18
|
+
port?: number
|
|
19
|
+
/** Bun workers of each child; default 1 (R13.4). */
|
|
20
|
+
workers?: number
|
|
21
|
+
out?: TuiOut
|
|
22
|
+
env?: Record<string, string | undefined>
|
|
23
|
+
/** Fires with the user-visible URL once the proxy listens (tests). */
|
|
24
|
+
onListening?: (url: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEBOUNCE_MS = 150
|
|
28
|
+
const BOOT_TIMEOUT_MS = 60_000
|
|
29
|
+
const TAIL_LINES = 40
|
|
30
|
+
const cliPath = join(import.meta.dir, '..', 'cli.ts')
|
|
31
|
+
|
|
32
|
+
/** A free loopback port (bind port 0, read it, release it). */
|
|
33
|
+
export function freePort(): number {
|
|
34
|
+
const s = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') })
|
|
35
|
+
const p = s.port as number
|
|
36
|
+
s.stop(true)
|
|
37
|
+
return p
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pumpLines(stream: ReadableStream<Uint8Array> | null | undefined, onLine: (l: string) => void): void {
|
|
41
|
+
if (!stream) return
|
|
42
|
+
void (async () => {
|
|
43
|
+
const dec = new TextDecoder()
|
|
44
|
+
let buf = ''
|
|
45
|
+
for await (const chunk of stream) {
|
|
46
|
+
buf += dec.decode(chunk, { stream: true })
|
|
47
|
+
let i = buf.indexOf('\n')
|
|
48
|
+
while (i >= 0) {
|
|
49
|
+
onLine(buf.slice(0, i).replace(/\r$/, ''))
|
|
50
|
+
buf = buf.slice(i + 1)
|
|
51
|
+
i = buf.indexOf('\n')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (buf) onLine(buf)
|
|
55
|
+
})()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** L19/L21 dev swap: the built stylesheets of the old and the new generation, paired by source stem
|
|
59
|
+
* (`<stem>-<hex10>.css`), as `/_brust/client/<old>` → `/_brust/client/<new>`. `null` when the pairing is not
|
|
60
|
+
* one-to-one (a stylesheet was added or removed, or two sources share a stem): the page must reload. */
|
|
61
|
+
export function cssSwapMap(oldDir: string, newDir: string): Record<string, string> | null {
|
|
62
|
+
const byStem = (dir: string) => {
|
|
63
|
+
const m = new Map<string, string>()
|
|
64
|
+
let files: string[] = []
|
|
65
|
+
try {
|
|
66
|
+
files = readdirSync(join(dir, 'client'))
|
|
67
|
+
} catch {}
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
const s = /^(.*)-[0-9a-f]{10}\.css$/.exec(f)?.[1]
|
|
70
|
+
if (s === undefined) continue
|
|
71
|
+
if (m.has(s)) return null
|
|
72
|
+
m.set(s, f)
|
|
73
|
+
}
|
|
74
|
+
return m
|
|
75
|
+
}
|
|
76
|
+
const a = byStem(oldDir)
|
|
77
|
+
const b = byStem(newDir)
|
|
78
|
+
if (!a || !b || a.size !== b.size) return null
|
|
79
|
+
const map: Record<string, string> = {}
|
|
80
|
+
for (const [stem, f] of a) {
|
|
81
|
+
const g = b.get(stem)
|
|
82
|
+
if (g === undefined) return null
|
|
83
|
+
if (g !== f) map[`/_brust/client/${f}`] = `/_brust/client/${g}`
|
|
84
|
+
}
|
|
85
|
+
return map
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The child server's own tracing chatter is not forwarded; the app's console output is. */
|
|
89
|
+
const NOISE = /\b(INFO|DEBUG)\b|^\[brust\] (listening on|ready)|^\s*$/
|
|
90
|
+
|
|
91
|
+
interface Child {
|
|
92
|
+
proc: ReturnType<typeof Bun.spawn>
|
|
93
|
+
port: number
|
|
94
|
+
dir: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function runBunDev(o: BunDevOptions): Promise<number> {
|
|
98
|
+
const appRoot = resolve(o.appRoot)
|
|
99
|
+
const entry = resolve(appRoot, o.entry)
|
|
100
|
+
const out: TuiOut = o.out ?? (process.stdout as unknown as TuiOut)
|
|
101
|
+
const env = o.env ?? process.env
|
|
102
|
+
const mode = tuiMode(out, env)
|
|
103
|
+
const tui = new Tui(out, mode.interactive, mode.color)
|
|
104
|
+
const cfg = await loadConfig(appRoot, o.port !== undefined ? { port: o.port } : {})
|
|
105
|
+
const workers = o.workers ?? 1
|
|
106
|
+
const devRoot = join(appRoot, '.brust', 'dev')
|
|
107
|
+
rmSync(devRoot, { recursive: true, force: true })
|
|
108
|
+
mkdirSync(devRoot, { recursive: true })
|
|
109
|
+
|
|
110
|
+
// R14.1: only this process and its children know the token; the core traces a request only if it carries it.
|
|
111
|
+
const devToken = crypto.randomUUID()
|
|
112
|
+
let counter = 0
|
|
113
|
+
let gen = 0
|
|
114
|
+
let state: 'building' | 'ready' | 'error' = 'building'
|
|
115
|
+
let diag: Diag | null = null
|
|
116
|
+
let active: Child | null = null
|
|
117
|
+
let buildMs: number | undefined
|
|
118
|
+
let warnings: string[] = []
|
|
119
|
+
let last: { method: string; path: string; status: number; tiers?: { static: number; native: number; react: number } } | undefined
|
|
120
|
+
let stopping = false
|
|
121
|
+
let url = ''
|
|
122
|
+
/** Processes spawned by an in-flight cycle (a build, a booting child): `stop()` must reach them (R13.5, own PIDs only). */
|
|
123
|
+
const inflight = new Set<ReturnType<typeof Bun.spawn>>()
|
|
124
|
+
|
|
125
|
+
const publish = () =>
|
|
126
|
+
tui.setStatus({
|
|
127
|
+
state,
|
|
128
|
+
url,
|
|
129
|
+
gen,
|
|
130
|
+
buildMs,
|
|
131
|
+
last,
|
|
132
|
+
warnings,
|
|
133
|
+
error: diag && state === 'error' ? `${diag.rule} ${diag.file ? `${diag.file}${diag.line ? `:${diag.line}` : ''} ` : ''}${diag.message}`.trim() : undefined,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const devMeta = (): unknown | null => {
|
|
137
|
+
try {
|
|
138
|
+
return active ? JSON.parse(readFileSync(join(active.dir, 'dev.json'), 'utf8')) : null
|
|
139
|
+
} catch {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const proxy: DevProxy = startProxy(
|
|
145
|
+
{ hostname: cfg.host, port: cfg.port, token: devToken },
|
|
146
|
+
{
|
|
147
|
+
gen: () => gen,
|
|
148
|
+
state: () => state,
|
|
149
|
+
diag: () => diag,
|
|
150
|
+
upstream: () => active?.port ?? null,
|
|
151
|
+
meta: devMeta,
|
|
152
|
+
onRequest(r) {
|
|
153
|
+
tui.log(`${r.method} ${r.path} ${r.status} ${r.ms}ms${r.cache ? ` ${r.cache}` : ''}`)
|
|
154
|
+
if (!r.path.startsWith('/_brust/')) {
|
|
155
|
+
const rendered = (r.trace as { rendered?: string[] } | null)?.rendered
|
|
156
|
+
last = { method: r.method, path: r.path, status: r.status, ...(rendered ? { tiers: tierCounts(rendered, devMeta() as Parameters<typeof tierCounts>[1]) } : {}) }
|
|
157
|
+
publish()
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
url = `http://${cfg.host}:${proxy.port}`
|
|
163
|
+
o.onListening?.(url)
|
|
164
|
+
|
|
165
|
+
/** Build into `dir`; a child process, so module caches never leak between builds. */
|
|
166
|
+
async function build(dir: string): Promise<{ ok: boolean; text: string; ms: number }> {
|
|
167
|
+
const t0 = performance.now()
|
|
168
|
+
const p = Bun.spawn([process.execPath, cliPath, 'build', o.entry, '--out-dir', dir, '--dev'], {
|
|
169
|
+
cwd: appRoot,
|
|
170
|
+
env: { ...process.env, NODE_ENV: 'development', BRUST_BUILD_MODE: 'development' },
|
|
171
|
+
stdout: 'pipe',
|
|
172
|
+
stderr: 'pipe',
|
|
173
|
+
})
|
|
174
|
+
inflight.add(p)
|
|
175
|
+
const [so, se, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
176
|
+
inflight.delete(p)
|
|
177
|
+
return { ok: code === 0, text: `${so}\n${se}`, ms: Math.round(performance.now() - t0) }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Boot `brust start` over `dir`; resolves once `[brust] ready`, rejects with the output tail. */
|
|
181
|
+
function boot(dir: string): Promise<Child> {
|
|
182
|
+
const port = freePort()
|
|
183
|
+
const proc = Bun.spawn([process.execPath, cliPath, 'start', '--dist-dir', dir, '--entry', entry, '--workers', String(workers)], {
|
|
184
|
+
cwd: appRoot,
|
|
185
|
+
env: {
|
|
186
|
+
...process.env,
|
|
187
|
+
NODE_ENV: 'development',
|
|
188
|
+
BRUST_PORT: String(port),
|
|
189
|
+
BRUST_ADDR: '127.0.0.1',
|
|
190
|
+
BRUST_WORKERS: String(workers),
|
|
191
|
+
BRUST_DIST_DIR: dir,
|
|
192
|
+
BRUST_APP_ENTRY: entry,
|
|
193
|
+
BRUST_DEV_TOKEN: devToken,
|
|
194
|
+
},
|
|
195
|
+
stdout: 'pipe',
|
|
196
|
+
stderr: 'pipe',
|
|
197
|
+
})
|
|
198
|
+
inflight.add(proc)
|
|
199
|
+
const tail: string[] = []
|
|
200
|
+
let ready = false
|
|
201
|
+
return new Promise<Child>((resolveBoot, reject) => {
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
proc.kill('SIGKILL')
|
|
204
|
+
reject(new Error(`the new build did not become ready within ${BOOT_TIMEOUT_MS / 1000}s\n${tail.join('\n')}`))
|
|
205
|
+
}, BOOT_TIMEOUT_MS)
|
|
206
|
+
const line = (l: string) => {
|
|
207
|
+
tail.push(l)
|
|
208
|
+
if (tail.length > TAIL_LINES) tail.shift()
|
|
209
|
+
if (!ready && /\[brust\] ready/.test(l)) {
|
|
210
|
+
ready = true
|
|
211
|
+
clearTimeout(timer)
|
|
212
|
+
resolveBoot({ proc, port, dir })
|
|
213
|
+
} else if (ready && !NOISE.test(l)) tui.log(l)
|
|
214
|
+
}
|
|
215
|
+
pumpLines(proc.stdout as ReadableStream<Uint8Array>, line)
|
|
216
|
+
pumpLines(proc.stderr as ReadableStream<Uint8Array>, line)
|
|
217
|
+
void proc.exited.then((code) => {
|
|
218
|
+
inflight.delete(proc)
|
|
219
|
+
clearTimeout(timer)
|
|
220
|
+
if (!ready) reject(new Error(`the new build exited with code ${code} before it was ready\n${tail.join('\n')}`))
|
|
221
|
+
else if (active?.proc === proc && !stopping) tui.log(`[brust] the server process exited (code ${code})`)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function stopChild(c: Child): Promise<void> {
|
|
227
|
+
c.proc.kill('SIGINT')
|
|
228
|
+
if ((await Promise.race([c.proc.exited, Bun.sleep(4000).then(() => undefined)])) === undefined) {
|
|
229
|
+
c.proc.kill('SIGKILL')
|
|
230
|
+
await c.proc.exited
|
|
231
|
+
}
|
|
232
|
+
rmSync(c.dir, { recursive: true, force: true })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function fail(d: Diag): void {
|
|
236
|
+
state = 'error'
|
|
237
|
+
diag = d
|
|
238
|
+
proxy.broadcast({ type: 'error', diag: d })
|
|
239
|
+
tui.log(`[brust] ${d.rule}${d.file ? ` ${d.file}${d.line ? `:${d.line}${d.col ? `:${d.col}` : ''}` : ''}` : ''} ${d.message}`)
|
|
240
|
+
publish()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One build -> boot -> swap cycle. */
|
|
244
|
+
async function cycle(cssOnly = false): Promise<void> {
|
|
245
|
+
state = 'building'
|
|
246
|
+
publish()
|
|
247
|
+
const g = ++counter
|
|
248
|
+
const dir = join(devRoot, `g${g}`)
|
|
249
|
+
const b = await build(dir)
|
|
250
|
+
if (stopping) {
|
|
251
|
+
rmSync(dir, { recursive: true, force: true })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
warnings = parseWarnings(b.text)
|
|
255
|
+
if (!b.ok) {
|
|
256
|
+
rmSync(dir, { recursive: true, force: true })
|
|
257
|
+
fail(parseBuildError(b.text) ?? { rule: 'build', message: b.text.trim().split('\n').pop() ?? 'build failed', raw: b.text.trim() })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
let child: Child
|
|
261
|
+
try {
|
|
262
|
+
child = await boot(dir)
|
|
263
|
+
} catch (e) {
|
|
264
|
+
rmSync(dir, { recursive: true, force: true })
|
|
265
|
+
if (stopping) return
|
|
266
|
+
const raw = (e as Error).message
|
|
267
|
+
fail({ rule: 'boot', message: raw.split('\n')[0] ?? raw, raw })
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
// Stopped while this child booted, or a newer generation already took over: this child must not be adopted.
|
|
271
|
+
if (stopping || g < gen) {
|
|
272
|
+
await stopChild(child)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const old = active
|
|
276
|
+
active = child
|
|
277
|
+
gen = g
|
|
278
|
+
buildMs = b.ms
|
|
279
|
+
state = 'ready'
|
|
280
|
+
diag = null
|
|
281
|
+
// A stylesheet-only edit (L19/L21): the pages keep their DOM and swap their brust CSS links.
|
|
282
|
+
const swap = cssOnly && old ? cssSwapMap(old.dir, child.dir) : null
|
|
283
|
+
if (swap) {
|
|
284
|
+
proxy.broadcast({ type: 'css-swap', gen, map: swap })
|
|
285
|
+
tui.log(`[brust] css rebuilt (${Object.keys(swap).length} stylesheet${Object.keys(swap).length === 1 ? '' : 's'} swapped, no reload)`)
|
|
286
|
+
} else proxy.broadcast({ type: 'reload', gen })
|
|
287
|
+
publish()
|
|
288
|
+
if (old) void stopChild(old)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const isCss = (rel: string) => rel.startsWith('public/') && rel.endsWith('.css')
|
|
292
|
+
|
|
293
|
+
const loop = watchLoop({
|
|
294
|
+
root: appRoot,
|
|
295
|
+
ignored: (rel) => defaultIgnored(rel, 'dist'),
|
|
296
|
+
debounceMs: DEBOUNCE_MS,
|
|
297
|
+
async run(changed) {
|
|
298
|
+
// A change to public/*.css skips the build: copy it into the active dist and swap the <link> (R13.3).
|
|
299
|
+
if (active && state === 'ready' && [...changed].every((r) => isCss(r) && existsSync(join(appRoot, r)))) {
|
|
300
|
+
const v = Date.now()
|
|
301
|
+
for (const rel of changed) {
|
|
302
|
+
cpSync(join(appRoot, rel), join(active.dir, rel))
|
|
303
|
+
proxy.broadcast({ type: 'css', href: `/${rel}`, v })
|
|
304
|
+
}
|
|
305
|
+
tui.log(`[brust] css ${[...changed].join(', ')} (no reload)`)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
tui.log(`[brust] change detected: ${[...changed].slice(0, 3).join(', ')}${changed.size > 3 ? ` +${changed.size - 3}` : ''}`)
|
|
309
|
+
// Only built stylesheets changed (L19/L21): rebuild, boot, then swap the links instead of reloading.
|
|
310
|
+
await cycle(active !== null && state === 'ready' && [...changed].every((r) => r.endsWith('.css') && !isCss(r)))
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
let exit: (code: number) => void = () => {}
|
|
315
|
+
const done = new Promise<number>((r) => {
|
|
316
|
+
exit = r
|
|
317
|
+
})
|
|
318
|
+
let stdinRaw = false
|
|
319
|
+
const stop = async () => {
|
|
320
|
+
if (stopping) return
|
|
321
|
+
stopping = true
|
|
322
|
+
loop.close()
|
|
323
|
+
proxy.stop()
|
|
324
|
+
if (stdinRaw) {
|
|
325
|
+
process.stdin.setRawMode(false)
|
|
326
|
+
process.stdin.pause()
|
|
327
|
+
}
|
|
328
|
+
tui.close()
|
|
329
|
+
// A build or a booting child of an in-flight cycle: SIGINT, then SIGKILL (own PIDs only).
|
|
330
|
+
const mid = [...inflight]
|
|
331
|
+
for (const p of mid) p.kill('SIGINT')
|
|
332
|
+
await Promise.all(mid.map(async (p) => {
|
|
333
|
+
if ((await Promise.race([p.exited, Bun.sleep(4000).then(() => undefined)])) === undefined) p.kill('SIGKILL')
|
|
334
|
+
}))
|
|
335
|
+
if (active) await stopChild(active)
|
|
336
|
+
rmSync(devRoot, { recursive: true, force: true })
|
|
337
|
+
exit(0)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (mode.interactive && process.stdin.isTTY) {
|
|
341
|
+
stdinRaw = true
|
|
342
|
+
process.stdin.setRawMode(true)
|
|
343
|
+
process.stdin.resume()
|
|
344
|
+
process.stdin.on('data', (b) => {
|
|
345
|
+
for (const k of b.toString()) {
|
|
346
|
+
if (k === 'q' || k === '\x03') void stop()
|
|
347
|
+
else if (k === 'r') proxy.broadcast({ type: 'reload' })
|
|
348
|
+
else if (k === 'o') Bun.spawn([process.platform === 'darwin' ? 'open' : 'xdg-open', url], { stdout: 'ignore', stderr: 'ignore' })
|
|
349
|
+
else if (k === 'w') for (const w of warnings.length ? warnings : ['no warnings']) tui.log(`warning ${w}`)
|
|
350
|
+
else if (k === 'c') tui.clear()
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
;(out as unknown as NodeJS.WriteStream).on?.('resize', () => tui.resize())
|
|
354
|
+
}
|
|
355
|
+
process.on('SIGINT', () => void stop())
|
|
356
|
+
process.on('SIGTERM', () => void stop())
|
|
357
|
+
|
|
358
|
+
tui.log(`[brust] dev server on ${url} — ${HOTKEYS}`)
|
|
359
|
+
publish()
|
|
360
|
+
// The first build goes through the loop too, so it is single-flight with every later one: an edit saved while it
|
|
361
|
+
// runs waits for it and then builds on top (never a second cycle racing the first).
|
|
362
|
+
loop.trigger('.')
|
|
363
|
+
void loop.idle()
|
|
364
|
+
return done
|
|
365
|
+
}
|