@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.
Files changed (58) hide show
  1. package/README.md +64 -6
  2. package/native/index.d.ts +65 -2
  3. package/native/index.js +59 -54
  4. package/package.json +16 -8
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +199 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +48 -12
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +315 -0
  19. package/src/build/compile.ts +41 -4
  20. package/src/build/config.ts +255 -10
  21. package/src/build/css.ts +286 -0
  22. package/src/build/index.ts +199 -16
  23. package/src/build/manifest.ts +41 -4
  24. package/src/build/scan.ts +59 -20
  25. package/src/build/ssg-render.ts +65 -0
  26. package/src/build/ssg.ts +146 -0
  27. package/src/build/static-worker.ts +91 -0
  28. package/src/build/static.ts +258 -0
  29. package/src/build-time.ts +10 -0
  30. package/src/cache-adapter/bridge.ts +91 -0
  31. package/src/cache-adapter/conformance.ts +226 -0
  32. package/src/cache-adapter/index.ts +10 -0
  33. package/src/cache-adapter/load.ts +144 -0
  34. package/src/cache-adapter/memory.ts +229 -0
  35. package/src/cache-adapter/runtime.ts +124 -0
  36. package/src/cache-adapter/types.ts +96 -0
  37. package/src/cache-adapter/workers.ts +118 -0
  38. package/src/cache.ts +4 -1
  39. package/src/cf.ts +19 -0
  40. package/src/cli.ts +50 -2
  41. package/src/config.ts +127 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +365 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +356 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +236 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +81 -0
  51. package/src/dev.ts +93 -0
  52. package/src/handlers.ts +282 -0
  53. package/src/index.ts +3 -1
  54. package/src/native.ts +38 -1
  55. package/src/routes.ts +20 -1
  56. package/src/run.ts +46 -9
  57. package/src/server-handlers.ts +32 -0
  58. package/src/worker.ts +125 -245
@@ -2,7 +2,7 @@
2
2
  // elysia is type-only here. Differences from 0.1.x runtime/treaty.ts: docs/actions.md "The client".
3
3
  import { type Treaty, treaty } from '@elysia/eden/treaty2'
4
4
  import type { AnyElysia } from 'elysia'
5
- import { DEFAULT_ACTION_PREFIX } from './prefix'
5
+ import { type ActionCredentials, DEFAULT_ACTION_PREFIX } from './prefix'
6
6
 
7
7
  export { DEFAULT_ACTION_PREFIX }
8
8
  export type { Treaty }
@@ -19,7 +19,17 @@ export interface ClientOptions<Head extends Record<string, unknown> = {}>
19
19
  init?: Treaty.Config<Head>['fetch']
20
20
  }
21
21
 
22
- /** 0.1.x runtime/treaty.ts:86-93, verbatim order. */
22
+ interface PageGlobals {
23
+ __BRUST_ACTION_PREFIX__?: string
24
+ /** L16 R16.1: injected from `[actions] base_url` / BRUST_ACTIONS_BASE_URL / run({ config: { actionsBaseUrl } }). */
25
+ __BRUST_ACTION_BASE__?: string
26
+ /** L16 R16.2: injected from `[actions] credentials`. */
27
+ __BRUST_ACTION_CREDENTIALS__?: ActionCredentials
28
+ }
29
+ const page = () => globalThis as PageGlobals
30
+
31
+ /** 0.1.x runtime/treaty.ts:86-93, verbatim order. A per-call `baseUrl` keeps the default prefix; a base that comes
32
+ * from the page global (L16) pairs with the page's injected prefix, both being the same deployment's config. */
23
33
  export function resolvePrefix(opts?: { prefix?: string; baseUrl?: string }): string {
24
34
  if (opts?.prefix) return opts.prefix
25
35
  if (opts?.baseUrl !== undefined) return DEFAULT_ACTION_PREFIX
@@ -31,7 +41,8 @@ export function resolvePrefix(opts?: { prefix?: string; baseUrl?: string }): str
31
41
 
32
42
  /** 0.1.x runtime/treaty.ts:95-106; message names `client`. */
33
43
  export function resolveBaseUrl(opts?: { baseUrl?: string }): string {
34
- const b = opts?.baseUrl
44
+ // R16.1 order: opts.baseUrl, the page's __BRUST_ACTION_BASE__, relative.
45
+ const b = opts?.baseUrl ?? page().__BRUST_ACTION_BASE__
35
46
  if (b === undefined) return ''
36
47
  if (!/^https?:\/\//.test(b))
37
48
  throw new Error(
@@ -44,11 +55,17 @@ export function client<const App extends AnyElysia, Head extends Record<string,
44
55
  opts: ClientOptions<Head> = {},
45
56
  ): Treaty.Instance<App, Head> {
46
57
  const { prefix: _p, baseUrl: _b, fetch: fetcher, init, ...rest } = opts
58
+ // R16.2: the page's credentials default (`opts.init` wins); only when it differs from the browser default.
59
+ const creds = page().__BRUST_ACTION_CREDENTIALS__
60
+ const defaults = creds !== undefined && creds !== 'same-origin' && typeof init !== 'function' ? { credentials: creds } : undefined
47
61
  // keepDomain: Eden would rewrite a relative base to `https:///…` (probe kd.ts).
48
- return treaty<App, Head>(resolveBaseUrl(opts) + resolvePrefix(opts), {
62
+ // The root prefix (an actions-only deployment) adds nothing to the base: `https://api.example.com/team`, not `//team`.
63
+ const base = resolveBaseUrl(opts)
64
+ const prefix = resolvePrefix(opts)
65
+ return treaty<App, Head>(base !== '' && prefix === '/' ? base : base + prefix, {
49
66
  ...rest,
50
67
  keepDomain: true,
51
68
  ...(fetcher ? { fetcher } : {}),
52
- ...(init ? { fetch: init } : {}),
69
+ ...(init || defaults ? { fetch: typeof init === 'function' ? init : { ...defaults, ...init } } : {}),
53
70
  })
54
71
  }
@@ -14,7 +14,8 @@ export function mountActions(app: unknown, prefix: string, what = 'actions entry
14
14
  throw new Error(
15
15
  `${what}: build the app with new Elysia({ adapter: brust() }) — import { brust } from '@brust/core/elysia'`,
16
16
  )
17
- return new Elysia({ adapter: brust(), prefix }).use(app)
17
+ // L16 R16.4: the root prefix of an actions-only build is no prefix at all (`prefix: '/'` would double the slash).
18
+ return new Elysia({ adapter: brust(), ...(prefix === '/' ? {} : { prefix }) }).use(app)
18
19
  }
19
20
 
20
21
  export async function loadActionsApp(file: string, prefix: string): Promise<AnyElysia> {
@@ -5,8 +5,10 @@ export const DEFAULT_MAX_RESPONSE_BYTES = 8_388_608
5
5
  export const MAX_ACTION_BYTES = 67_108_864
6
6
  const SEG = /^[A-Za-z0-9._~-]+$/
7
7
 
8
- /** Why `p` cannot be the actions prefix, or null. Segment-safe like the server's match. */
9
- export function prefixError(p: unknown): string | null {
8
+ /** Why `p` cannot be the actions prefix, or null. Segment-safe like the server's match. `root`: an actions-only
9
+ * build (L16 R16.4) may mount at `/`; a normal build never may (its page routes would be unreachable). */
10
+ export function prefixError(p: unknown, root = false): string | null {
11
+ if (root && p === '/') return null
10
12
  if (typeof p !== 'string' || !p.startsWith('/') || p.length < 2 || p.endsWith('/'))
11
13
  return `must start with '/', be longer than '/' and have no trailing '/' (got ${JSON.stringify(p)})`
12
14
  const segs = p.slice(1).split('/')
@@ -21,3 +23,23 @@ export function prefixError(p: unknown): string | null {
21
23
  return `${p} is reserved for the server's own /_brust/ paths`
22
24
  return null
23
25
  }
26
+
27
+ /** L16 R16.1: `base_url` is an absolute http(s) origin: scheme, host, optional port; no path, query, fragment or userinfo. */
28
+ export function baseUrlError(v: unknown): string | null {
29
+ if (typeof v !== 'string' || !/^https?:\/\//.test(v))
30
+ return `must be an absolute http(s) origin such as https://api.example.com (got ${JSON.stringify(v)})`
31
+ let u: URL
32
+ try {
33
+ u = new URL(v)
34
+ } catch {
35
+ return `is not a valid URL (got ${JSON.stringify(v)})`
36
+ }
37
+ const authority = v.replace(/^https?:\/\//, '')
38
+ if (u.search !== '' || u.hash !== '' || u.username !== '' || u.password !== '' || /[/?#@\\\s]/.test(authority))
39
+ return `must be an origin only: no path, query, fragment or credentials (got ${JSON.stringify(v)})`
40
+ return null
41
+ }
42
+
43
+ /** L16 R16.2: the default `RequestInit.credentials` of the browser client. */
44
+ export const ACTION_CREDENTIALS = ['same-origin', 'include', 'omit'] as const
45
+ export type ActionCredentials = (typeof ACTION_CREDENTIALS)[number]
@@ -22,7 +22,7 @@ export interface FetchRequest {
22
22
  method: string
23
23
  url: string
24
24
  headers: [string, string][]
25
- /** `{heap: n}` (F82): more than 262144 bytes, fetched with `takeFetchBody(slot)`. */
25
+ /** `{heap: n}` (F82): more than 262144 bytes (`ACTION_INLINE_BODY_BYTES`, independent of the slot size), fetched with `takeFetchBody(slot)`. */
26
26
  body: { text: string } | { b64: string } | { heap: number } | null
27
27
  req: LoaderReq
28
28
  }
@@ -112,8 +112,34 @@ export function readFramed(view: Uint8Array, slot: number, slots: number, len: n
112
112
 
113
113
  const fail = (view: Uint8Array, slot: number, slots: number, msg: string) => writeFramed(view, slot, slots, 500, TEXT_PLAIN, enc.encode(msg))
114
114
 
115
+ /** The store-scoped core shared by `runFetch` and `fetchInScope`: R1/E5 — one store scope around the handler AND the
116
+ * body read (a lazy body may still touch stores); set-cookie values kept apart. */
117
+ export async function fetchParts(a: ActionsApp, req: Request): Promise<{ status: number; pairs: [string, string][]; body: Uint8Array }> {
118
+ return runInStoreContext(async () => {
119
+ const res = await a.fetch(req)
120
+ const pairs: [string, string][] = []
121
+ res.headers.forEach((v, k) => {
122
+ if (k !== 'set-cookie') pairs.push([k, v])
123
+ })
124
+ for (const c of res.headers.getSetCookie()) pairs.push(['set-cookie', c])
125
+ return { status: res.status, pairs, body: new Uint8Array(await res.arrayBuffer()) }
126
+ })
127
+ }
128
+
129
+ /** Host-agnostic entry (Cloudflare `action(req)`): the same scope and framing-free parts as `runFetch`, as a Response. */
130
+ export async function fetchInScope(a: ActionsApp, req: Request): Promise<Response> {
131
+ const { status, pairs, body } = await fetchParts(a, req)
132
+ const headers = new Headers()
133
+ for (const [k, v] of pairs) headers.append(k, v)
134
+ return new Response([101, 204, 205, 304].includes(status) ? null : (body as BodyInit), { status, headers })
135
+ }
136
+
137
+ /** The `action` export of a worker whose app has no `[actions]`. */
138
+ export const noActions = async (_: Request): Promise<Response> =>
139
+ new Response('brust: actions are not configured in this worker', { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } })
140
+
115
141
  /** The `fetch` call (makeDispatch). Never rejects, never writes JSON: every outcome is a frame. A body of
116
- * more than 262144 bytes arrives by `io.takeBody` (A1 `{heap}`); a response that does not fit the slot leaves
142
+ * more than 262144 bytes (`ACTION_INLINE_BODY_BYTES`, not the slot size) arrives by `io.takeBody` (A1 `{heap}`); a response that does not fit the slot leaves
117
143
  * by `io.putReply` with only status/headers/`heap` in the slot (F82), unless it exceeds `max_response_bytes`. */
118
144
  export async function runFetch(requestJson: string, view: Uint8Array, slot: number, slots: number, io: HeapIo = nativeHeapIo): Promise<number> {
119
145
  const a = app
@@ -128,15 +154,7 @@ export async function runFetch(requestJson: string, view: Uint8Array, slot: numb
128
154
  }
129
155
  const req = toRequest(fr, heapBody)
130
156
  // R1/E5: one store scope around the handler AND the body read (a lazy body may still touch stores).
131
- const out = await runInStoreContext(async () => {
132
- const res = await a.fetch(req)
133
- const pairs: [string, string][] = []
134
- res.headers.forEach((v, k) => {
135
- if (k !== 'set-cookie') pairs.push([k, v])
136
- })
137
- for (const c of res.headers.getSetCookie()) pairs.push(['set-cookie', c])
138
- return { status: res.status, pairs, body: new Uint8Array(await res.arrayBuffer()) }
139
- })
157
+ const out = await fetchParts(a, req)
140
158
  const sub = Math.floor(view.byteLength / Math.max(1, slots))
141
159
  const metaLen = (m: FetchMeta) => enc.encode(JSON.stringify(m)).length
142
160
  if (4 + metaLen({ status: out.status, headers: out.pairs }) + out.body.length <= sub) return writeFramed(view, slot, slots, out.status, out.pairs, out.body)
package/src/browser.ts CHANGED
@@ -1,7 +1,9 @@
1
+ /// <reference path="./css-modules.d.ts" />
1
2
  // `@brust/core` under the `browser` export condition (and what every browser bundle of
2
3
  // `brust build` resolves the package root to): only the side-effect-free pieces. No `run`, no
3
4
  // config, no napi addon — a react island may import `cache` from the package root.
4
5
  import { cacheComponent } from './cache-core'
6
+ export { buildTime } from './build-time'
5
7
  import type { InvalidateArgs, InvalidateResult } from './native'
6
8
 
7
9
  function invalidate(_args: InvalidateArgs): InvalidateResult {
@@ -26,4 +28,4 @@ export {
26
28
  Outlet,
27
29
  redirect,
28
30
  } from './routes'
29
- export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
31
+ export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, RouteSsgConfig, Verdict } from './routes'
@@ -0,0 +1,86 @@
1
+ // `brust build --actions-only` (gaps round L16, R16.3/R16.4): a dist with NO page routes: the actions app behind
2
+ // the same Rust admission (method gate, body caps, `Content-Encoding`, the cross-site guard, CORS), every other
3
+ // path a 404 problem+json. With `--target cloudflare` it also writes `<out>/cf/`: a Worker serving only actions
4
+ // (no Static Assets). The routes entry is neither read nor required.
5
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
7
+ import { buildActions } from './bundle'
8
+ import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
9
+ import type { ActionsBuildConfig, BuildConfig } from './config'
10
+ import { BuildError } from './errors'
11
+ import { MANIFEST_VERSION, type ManifestJson } from './manifest'
12
+
13
+ const MARKER = '.brust'
14
+
15
+ export async function runActionsOnly(o: {
16
+ appRoot: string
17
+ dist: string
18
+ config: BuildConfig
19
+ target: 'bun' | 'cloudflare' | 'static' | undefined
20
+ wasmFromSource: boolean
21
+ statOk: (p: string) => boolean
22
+ assertSafeOutDir: (dist: string, appRoot: string, keep: string[]) => void
23
+ log: (s: string) => void
24
+ }): Promise<void> {
25
+ const { appRoot, dist, config } = o
26
+ const actions: ActionsBuildConfig | null = config.actions
27
+ if (!actions) throw new BuildError('actions-only', '--actions-only needs an [actions] table in brust.toml')
28
+ if (o.target === 'static') throw new BuildError('actions-only', '--actions-only builds no pages: it cannot be combined with --target static')
29
+ const entry = resolve(appRoot, actions.entry)
30
+ const rel = relative(appRoot, entry)
31
+ if (isAbsolute(actions.entry) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !o.statOk(entry))
32
+ throw new BuildError('actions-entry', `actions.entry ${actions.entry} is not a file inside the app`)
33
+ o.assertSafeOutDir(dist, appRoot, [entry])
34
+ if (config.cache) o.log('[brust] actions-only: [cache] and route options do not apply (ignored)')
35
+ if (actions.baseUrl !== undefined) o.log('[brust] actions-only: [actions] base_url is for the pages that call these actions (ignored here)')
36
+ const cf: CfPrepared | null = o.target === 'cloudflare' ? await prepareCloudflare(appRoot, config.cloudflare, { fromSource: o.wasmFromSource, env: process.env }) : null
37
+
38
+ const tmp = join(dirname(dist), `.${basename(dist)}.brust-tmp`)
39
+ rmSync(tmp, { recursive: true, force: true })
40
+ mkdirSync(tmp, { recursive: true })
41
+ try {
42
+ const manifest: ManifestJson = {
43
+ version: MANIFEST_VERSION,
44
+ routes: [],
45
+ components: {},
46
+ assets: { runtime: '' },
47
+ jobs_module: 'jobs.js',
48
+ actions: { entry: await buildActions(tmp, entry), prefix: actions.prefix, maxBodyBytes: actions.maxBodyBytes, maxResponseBytes: actions.maxResponseBytes, crossSite: actions.crossSite },
49
+ actions_only: true,
50
+ }
51
+ mkdirSync(join(tmp, 'jinja'), { recursive: true })
52
+ writeFileSync(join(tmp, 'jobs.js'), '// generated by `brust build --actions-only`: no pages, so no jobs\nexport default {}\n')
53
+ writeFileSync(join(tmp, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
54
+ writeFileSync(join(tmp, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
55
+ writeFileSync(
56
+ join(tmp, 'index.js'),
57
+ [
58
+ '// generated by `brust build --actions-only` — `bun dist/index.js` serves the actions (do not edit)',
59
+ "process.env.BRUST_PREBUILT = '1'",
60
+ 'process.env.BRUST_DIST_DIR = import.meta.dir',
61
+ "const { run } = await import('@brust/core')",
62
+ 'await run()',
63
+ '',
64
+ ].join('\n'),
65
+ )
66
+ if (cf) {
67
+ const warnings = await buildCloudflare(
68
+ tmp,
69
+ { appRoot, entry, dist, compiled: new Map(), manifest, sourceOf: new Map(), actionsEntry: entry, actionsOnly: true, log: o.log },
70
+ cf,
71
+ )
72
+ for (const w of warnings) o.log(w)
73
+ }
74
+ rmSync(join(tmp, '.stage'), { recursive: true, force: true })
75
+ if (existsSync(dist)) {
76
+ const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
77
+ rmSync(old, { recursive: true, force: true })
78
+ renameSync(dist, old)
79
+ renameSync(tmp, dist)
80
+ rmSync(old, { recursive: true, force: true })
81
+ } else renameSync(tmp, dist)
82
+ } catch (e) {
83
+ rmSync(tmp, { recursive: true, force: true })
84
+ throw e
85
+ }
86
+ }
@@ -0,0 +1,199 @@
1
+ // Build-time evaluation of precompute slots (L2, G4/H1). The compiler marks the slots that read
2
+ // nothing request- or time-dependent (`ir.build_eval`) and emits one thunk per slot (`evalTs`).
3
+ // Here they are bundled once, run in a Bun subprocess (`TZ=UTC`, a timeout per slot, JSON-only
4
+ // values, a size cap) and the results go back to the compiler as slot -> literal overrides: the
5
+ // slot, and a job left with no slots, disappear. A pure helper that fails keeps its job with a
6
+ // `build-eval` warning; a failing `buildTime(…)` is a build error (the user asked for it).
7
+ import { join } from 'node:path'
8
+ import type { BunPlugin } from 'bun'
9
+ import { type SourceOf, PRODUCTION, sourceDirPlugin, stage, write } from './bundle'
10
+ import type { Compiled } from './compile'
11
+ import { BuildError } from './errors'
12
+
13
+ const V1 = { timeoutMs: 2_000, capBytes: 64 * 1024 }
14
+ const BUILD_TIME = { timeoutMs: 10_000, capBytes: 256 * 1024 }
15
+
16
+ interface Candidate {
17
+ key: string
18
+ bt: boolean
19
+ where: string
20
+ }
21
+
22
+ /** The runner the subprocess executes: loads the bundle, guards the sources of nondeterminism,
23
+ * and prints `S <i>` before and `R <i> <json>` / `E <i> <message>` after each slot. */
24
+ const RUNNER = `
25
+ const mod = await import(process.argv[2])
26
+ const start = Number(process.argv[3])
27
+ const slots = mod.default
28
+ const deny = (what) => () => { throw new Error('build-eval: ' + what + ' is not allowed (it differs between runs)') }
29
+ const RealDate = globalThis.Date
30
+ globalThis.Date = new Proxy(RealDate, {
31
+ construct(t, a) { if (a.length === 0) deny('new Date()')(); return Reflect.construct(t, a) },
32
+ apply() { deny('Date()')() },
33
+ get(t, p, r) { if (p === 'now') deny('Date.now()')(); return Reflect.get(t, p, r) },
34
+ })
35
+ Math.random = deny('Math.random()')
36
+ if (globalThis.performance) globalThis.performance.now = deny('performance.now()')
37
+ if (globalThis.crypto) { for (const k of ['randomUUID', 'getRandomValues']) { try { globalThis.crypto[k] = deny('crypto.' + k + '()') } catch {} } }
38
+ globalThis.fetch = deny('fetch()')
39
+ const out = (l) => process.stdout.write(l + '\\n')
40
+ const env = process.env
41
+ try { Object.defineProperty(process, 'env', { configurable: true, get: deny('process.env') }) } catch {}
42
+ const enc = (v, path) => {
43
+ if (v === null || typeof v === 'boolean' || typeof v === 'string') return v
44
+ if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error('not JSON: ' + path + ' is ' + v); return v }
45
+ if (Array.isArray(v)) return v.map((x, i) => { if (x === undefined && !(i in v)) throw new Error('not JSON: sparse array at ' + path); return enc(x, path + '[' + i + ']') })
46
+ if (typeof v === 'object' && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)) {
47
+ return { $: Object.keys(v).map((k) => { if (v[k] === undefined) throw new Error('not JSON: ' + path + '.' + k + ' is undefined'); return [k, enc(v[k], path + '.' + k)] }) }
48
+ }
49
+ throw new Error('not JSON: ' + path + ' is ' + (v === undefined ? 'undefined' : typeof v === 'object' ? (v.constructor && v.constructor.name) : typeof v))
50
+ }
51
+ for (let i = start; i < slots.length; i++) {
52
+ out('S ' + i)
53
+ try {
54
+ const v = await slots[i].fn()
55
+ out('R ' + i + ' ' + JSON.stringify(enc(v, 'value')))
56
+ } catch (e) {
57
+ out('E ' + i + ' ' + String(e && e.message ? e.message : e).split('\\n')[0])
58
+ }
59
+ }
60
+ `
61
+
62
+ /** The evaluator sees an explicit allowlist, never the build host's environment: `process.env`,
63
+ * `Bun.env` and `import.meta.env` all read from it, however they are spelled. */
64
+ function evalEnv(): Record<string, string> {
65
+ const env: Record<string, string> = { TZ: 'UTC', NODE_ENV: 'production' }
66
+ for (const k of ['PATH', 'HOME']) if (process.env[k] !== undefined) env[k] = process.env[k]!
67
+ return env
68
+ }
69
+
70
+ type Outcome = { ok: true; value: unknown } | { ok: false; reason: string }
71
+
72
+ /** Runs slots `[0, n)` of `entry` one after the other, restarting the subprocess after a timeout or crash. */
73
+ async function runAll(entry: string, runner: string, cands: Candidate[], cwd: string): Promise<Outcome[]> {
74
+ const out: (Outcome | undefined)[] = new Array(cands.length).fill(undefined)
75
+ let next = 0
76
+ while (next < cands.length) {
77
+ const proc = Bun.spawn([process.execPath, runner, entry, String(next)], { cwd, env: evalEnv(), stdout: 'pipe', stderr: 'pipe' })
78
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader()
79
+ const dec = new TextDecoder()
80
+ let buf = ''
81
+ let current = -1
82
+ let timer: ReturnType<typeof setTimeout> | undefined
83
+ let timedOut = false
84
+ const arm = (i: number) => {
85
+ clearTimeout(timer)
86
+ const limit = cands[i]!.bt ? BUILD_TIME.timeoutMs : V1.timeoutMs
87
+ timer = setTimeout(() => {
88
+ timedOut = true
89
+ proc.kill('SIGKILL')
90
+ }, limit)
91
+ }
92
+ const line = (l: string) => {
93
+ const m = /^([SRE]) (\d+)(?: (.*))?$/.exec(l)
94
+ if (!m) return
95
+ const i = Number(m[2])
96
+ if (m[1] === 'S') {
97
+ current = i
98
+ arm(i)
99
+ return
100
+ }
101
+ clearTimeout(timer)
102
+ const cap = cands[i]!.bt ? BUILD_TIME.capBytes : V1.capBytes
103
+ if (m[1] === 'E') out[i] = { ok: false, reason: m[3] ?? 'threw' }
104
+ else if ((m[3] ?? '').length > cap) out[i] = { ok: false, reason: `value is larger than ${cap / 1024} KiB` }
105
+ else out[i] = { ok: true, value: JSON.parse(m[3]!) }
106
+ next = i + 1
107
+ current = -1
108
+ }
109
+ for (;;) {
110
+ const { done, value } = await reader.read()
111
+ if (done) break
112
+ buf += dec.decode(value, { stream: true })
113
+ let nl: number
114
+ while ((nl = buf.indexOf('\n')) >= 0) {
115
+ line(buf.slice(0, nl))
116
+ buf = buf.slice(nl + 1)
117
+ }
118
+ }
119
+ clearTimeout(timer)
120
+ await proc.exited
121
+ if (current >= 0) {
122
+ const limit = (cands[current]!.bt ? BUILD_TIME.timeoutMs : V1.timeoutMs) / 1000
123
+ const stderr = (await new Response(proc.stderr as ReadableStream).text()).trim().split('\n')[0]
124
+ out[current] = { ok: false, reason: timedOut ? `timed out after ${limit} s` : `the evaluator exited${stderr ? `: ${stderr}` : ''}` }
125
+ next = current + 1
126
+ } else if (next < cands.length && !out[next]) {
127
+ // Exited before reporting anything (the bundle failed to load).
128
+ const stderr = (await new Response(proc.stderr as ReadableStream).text()).trim().split('\n').slice(0, 3).join(' | ')
129
+ for (let i = next; i < cands.length; i++) out[i] = { ok: false, reason: `the evaluator could not start${stderr ? `: ${stderr}` : ''}` }
130
+ next = cands.length
131
+ }
132
+ }
133
+ return out as Outcome[]
134
+ }
135
+
136
+ /** `@brust/core` inside the evaluator is the side-effect-free `buildTime` module, not the server. */
137
+ function corePlugin(appRoot: string): BunPlugin {
138
+ return {
139
+ name: 'brust-eval-core',
140
+ setup(b) {
141
+ b.onResolve({ filter: /^@brust\/core$/ }, () => ({ path: Bun.resolveSync('@brust/core/build-time', appRoot) }))
142
+ },
143
+ }
144
+ }
145
+
146
+ /** Evaluates every build-time slot of `compiled`; returns `key -> encoded JSON value` for those that
147
+ * succeeded. Throws a `BuildError` for a failing `buildTime(…)`; logs `warning build-eval` for a
148
+ * pure helper that fails (its job stays). */
149
+ export async function evaluateBuildSlots(
150
+ dist: string,
151
+ compiled: Map<string, Compiled>,
152
+ sourceOf: SourceOf,
153
+ appRoot: string,
154
+ log: (s: string) => void,
155
+ ): Promise<Record<string, unknown>> {
156
+ // `BRUST_BUILD_EVAL=0` turns evaluation off (every slot stays a job): the reference for dual runs.
157
+ if (process.env.BRUST_BUILD_EVAL === '0') return {}
158
+ const cands: Candidate[] = []
159
+ const imports: string[] = []
160
+ const entries: string[] = []
161
+ for (const c of compiled.values()) {
162
+ const slots = (c.ir.build_eval ?? []) as { key: string; build_time: boolean; line: number; col: number }[]
163
+ if (c.evalTs === undefined || slots.length === 0) continue
164
+ const file = join(dist, '.stage', 'eval', `${c.id}.eval.ts`)
165
+ write(file, c.evalTs)
166
+ stage(sourceOf, file, c)
167
+ const n = imports.length
168
+ imports.push(`import { slots as s${n} } from ${JSON.stringify(file)}`)
169
+ for (const s of slots) {
170
+ const where = `${c.file.slice(appRoot.length + 1)}:${s.line}:${s.col}`
171
+ cands.push({ key: s.key, bt: s.build_time, where })
172
+ entries.push(` { fn: s${n}[${JSON.stringify(s.key)}] },`)
173
+ }
174
+ }
175
+ if (cands.length === 0) return {}
176
+ const entry = join(dist, '.stage', 'eval-entry.ts')
177
+ write(entry, `${imports.join('\n')}\nexport default [\n${entries.join('\n')}\n]\n`)
178
+ const res = await Bun.build({
179
+ entrypoints: [entry],
180
+ target: 'bun',
181
+ format: 'esm',
182
+ define: PRODUCTION,
183
+ plugins: [corePlugin(appRoot), sourceDirPlugin(sourceOf, appRoot)],
184
+ })
185
+ if (!res.success) throw new BuildError('build-eval', `cannot bundle the build-time evaluation: ${res.logs.map(String).join('\n')}`)
186
+ const bundle = join(dist, '.stage', 'eval.js')
187
+ write(bundle, await res.outputs[0]!.text())
188
+ const runner = join(dist, '.stage', 'eval-run.js')
189
+ write(runner, RUNNER)
190
+ const results = await runAll(bundle, runner, cands, appRoot)
191
+ const values: Record<string, unknown> = {}
192
+ results.forEach((r, i) => {
193
+ const c = cands[i]!
194
+ if (r.ok) values[c.key] = r.value
195
+ else if (c.bt) throw new BuildError('build-time', `${c.where} buildTime(…) failed at build: ${r.reason}`)
196
+ else log(`warning build-eval ${c.where} not evaluated at build, the job stays: ${r.reason}`)
197
+ })
198
+ return values
199
+ }
@@ -0,0 +1,69 @@
1
+ // lucide-react icons rendered at build time (L23). The compiler records an `IconRequest` for every
2
+ // icon it could inline; here each is rendered with `react-dom/server` (the app's own React and the
3
+ // installed lucide-react, so the markup is exactly what React would produce for that version),
4
+ // dynamic props rendered as sentinels the compiler lowers back to attributes. The result goes back
5
+ // with the next compile (`iconSvgs`). An icon that cannot be rendered keeps the L22 island.
6
+ import type { Compiled } from './compile'
7
+
8
+ interface Request {
9
+ key: string
10
+ source: string
11
+ export: string
12
+ props: Record<string, unknown>
13
+ dynamic: string[]
14
+ provider: Record<string, unknown>
15
+ line: number
16
+ col: number
17
+ }
18
+
19
+ const sentinel = (name: string) => `__BRUST_ICON_${name}__`
20
+
21
+ export interface IconMarkup {
22
+ /** Dynamic props rendered as sentinels. */
23
+ svg: string
24
+ /** The same icon with the dynamic props absent: React's default for an `undefined` prop. */
25
+ base: string
26
+ }
27
+
28
+ export async function renderIcons(compiled: Map<string, Compiled>, appRoot: string, log: (s: string) => void): Promise<Record<string, IconMarkup>> {
29
+ const all: { c: Compiled; r: Request }[] = []
30
+ for (const c of compiled.values()) for (const r of (c.ir.icon_requests ?? []) as Request[]) all.push({ c, r })
31
+ if (all.length === 0) return {}
32
+ // One React for the icon module and the renderer: both resolve from the app.
33
+ const resolveFrom = (spec: string, from: string) => Bun.resolveSync(spec, from)
34
+ const server = await import(resolveFrom('react-dom/server', appRoot))
35
+ const react = await import(resolveFrom('react', appRoot))
36
+ const out: Record<string, IconMarkup> = {}
37
+ const mods = new Map<string, Record<string, unknown>>()
38
+ for (const { c, r } of all) {
39
+ const where = `${c.file.slice(appRoot.length + 1)}:${r.line}:${r.col}`
40
+ try {
41
+ let mod = mods.get(r.source)
42
+ if (!mod) {
43
+ mod = (await import(resolveFrom(r.source, appRoot))) as Record<string, unknown>
44
+ mods.set(r.source, mod)
45
+ }
46
+ const Comp = mod[r.export] as unknown
47
+ if (typeof Comp !== 'function' && (typeof Comp !== 'object' || Comp === null)) throw new Error(`${r.source} has no export \`${r.export}\``)
48
+ const render = (dynamic: Record<string, unknown>): string => {
49
+ let el = react.createElement(Comp, { ...r.props, ...dynamic })
50
+ if (Object.keys(r.provider).length > 0) {
51
+ const Provider = mod.LucideProvider
52
+ if (!Provider) throw new Error(`${r.source} has no LucideProvider (the installed lucide-react predates it)`)
53
+ el = react.createElement(Provider, r.provider, el)
54
+ }
55
+ const markup: string = server.renderToStaticMarkup(el)
56
+ if (!markup.startsWith('<svg') || !markup.endsWith('</svg>')) throw new Error('it does not render an <svg>')
57
+ if (/NaN|undefined/.test(markup)) throw new Error('a dynamic prop is combined with another (for example absoluteStrokeWidth with a dynamic size)')
58
+ return markup
59
+ }
60
+ const svg = render(Object.fromEntries(r.dynamic.map((d) => [d, sentinel(d)])))
61
+ for (const d of r.dynamic) if (!svg.includes(sentinel(d))) throw new Error(`prop \`${d}\` does not reach the markup`)
62
+ // `size={undefined}` renders React's default (or the provider's value): render that too.
63
+ out[r.key] = { svg, base: r.dynamic.length > 0 ? render({}) : svg }
64
+ } catch (e) {
65
+ log(`warning icon-render ${where} ${r.export} from ${r.source} stays a React island: ${e instanceof Error ? e.message : String(e)}`)
66
+ }
67
+ }
68
+ return out
69
+ }