@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
@@ -0,0 +1,282 @@
1
+ // The host-agnostic half of the worker (M4-CF lane 4): wire shapes + `makeHandlers`. No Bun, no
2
+ // napi, no `node:*` — the Cloudflare entry imports it through `@brust/core/server-handlers`.
3
+ // `worker.ts` re-exports everything here, so existing importers are unchanged.
4
+ import { installServerResolver, type StoreSnapshot } from './store/core-adapter'
5
+ import { collectSnapshot, getServerInstance, runInStoreContext, touchStores } from './store/server-context'
6
+ import { type FlatRoute, isHttpErrorTrigger, isVerdict, type LoaderReq, type Route, type Verdict } from './routes'
7
+
8
+ installServerResolver(getServerInstance)
9
+
10
+
11
+ // ---- wire shapes (protocol.rs, camelCase) ----
12
+
13
+ export interface LoaderRequest {
14
+ routeId: string
15
+ params: Record<string, string>
16
+ path: string
17
+ req: LoaderReq
18
+ /** M3-P P6: the chain has jobs — plan them in this call (`planJobs`). Optional to honour. */
19
+ plan?: boolean
20
+ }
21
+
22
+ export type LoaderResponse =
23
+ | { ok: true; data: Record<string, unknown>; headers?: Record<string, string>; store?: StoreSnapshot | null }
24
+ | { verdict: 'notFound'; data: unknown; store?: StoreSnapshot | null }
25
+ | { verdict: 'redirect'; location: string; status: number }
26
+ | { verdict: 'httpError'; status: number; body: string }
27
+ | { error: string }
28
+
29
+ export type JobKind = 'precompute' | 'ssr'
30
+
31
+ export interface JobCall {
32
+ /** Opaque, echoed back: `<componentId>/<jobId>[/<row>]` for a chain job,
33
+ * `<parentId>/<childId>_<k>/<jobId>[/<row>]` for an inlined child instance (pipeline.rs `plan_one`). */
34
+ id: string
35
+ componentId: string
36
+ kind: JobKind
37
+ // biome-ignore lint/suspicious/noExplicitAny: job inputs are arbitrary JSON
38
+ inputs: any
39
+ /** ssr: the react component to render (D6). */
40
+ target?: string
41
+ row?: number
42
+ }
43
+
44
+ export interface JobsRequest {
45
+ jobs: JobCall[]
46
+ }
47
+
48
+ export type JobResult = { id: string; value: unknown } | { id: string; error: string }
49
+
50
+ export interface JobsResponse {
51
+ results: JobResult[]
52
+ }
53
+
54
+ /** The default export of `<dist>/<manifest.jobs_module>` (built by Task 6): component id → job fns. */
55
+ // biome-ignore lint/suspicious/noExplicitAny: props are whatever the component takes
56
+ export type JobsModule = Record<string, { precompute?: (props: any) => unknown; ssr?: (props: any) => string | Promise<string> }>
57
+
58
+ /** The slice of `manifest.json` the worker reads: ssr job `literals` (S6 amendment). */
59
+ export interface ManifestLike {
60
+ routes?: { id: string; chain: string[] }[]
61
+ components: Record<
62
+ string,
63
+ {
64
+ jobs: { id: string; kind?: string; target?: string; literals?: Record<string, unknown> }[]
65
+ children?: { id: string }[]
66
+ stores?: string[]
67
+ }
68
+ >
69
+ jobs_module?: string
70
+ /** `brust build` (M3-F A3): the actions entry, relative to dist. Absent/null = no actions. */
71
+ actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number } | null
72
+ }
73
+
74
+ export function toJson(out: unknown): string {
75
+ try {
76
+ return JSON.stringify(out)
77
+ } catch (e) {
78
+ return JSON.stringify({ error: `response not serialisable: ${String(e)}` })
79
+ }
80
+ }
81
+
82
+ // ---- literals (S6 amendment: the worker merges an ssr job's `literals` over `call.inputs`) ----
83
+
84
+ /** `"<componentId>/<jobId>"` → that job record's `literals` (only records that carry some). */
85
+ export function literalsIndex(manifest: ManifestLike | undefined): Map<string, Record<string, unknown>> {
86
+ const out = new Map<string, Record<string, unknown>>()
87
+ for (const [cid, c] of Object.entries(manifest?.components ?? {}))
88
+ for (const j of c.jobs ?? []) if (j.literals && Object.keys(j.literals).length > 0) out.set(`${cid}/${j.id}`, j.literals)
89
+ return out
90
+ }
91
+
92
+ /** The manifest job id of a call: the segment after `componentId` in a chain id
93
+ * (`<cid>/<jobId>[/<row>]`), else the third segment of a child-instance id
94
+ * (`<parentId>/<cid>_<k>/<jobId>[/<row>]`). */
95
+ export function jobIdOf(call: Pick<JobCall, 'id' | 'componentId'>): string | undefined {
96
+ const parts = call.id.split('/')
97
+ return parts[0] === call.componentId ? parts[1] : parts[2]
98
+ }
99
+
100
+ // ---- touched stores (M3-F D4) ----
101
+
102
+ /** route id -> sorted unique union of `stores` over the route's chain, walking each component's
103
+ * `children[].id` and `jobs[].target` transitively. Routes with an empty union are absent. */
104
+ export function routeStoresIndex(manifest: ManifestLike | undefined): Map<string, string[]> {
105
+ const out = new Map<string, string[]>()
106
+ const comps = manifest?.components ?? {}
107
+ for (const r of manifest?.routes ?? []) {
108
+ const acc = new Set<string>()
109
+ const seen = new Set<string>()
110
+ const walk = (id: string) => {
111
+ if (seen.has(id)) return
112
+ seen.add(id)
113
+ const c = comps[id]
114
+ if (!c) return
115
+ for (const p of c.stores ?? []) acc.add(p)
116
+ for (const k of c.children ?? []) walk(k.id)
117
+ for (const j of c.jobs ?? []) if (j.target) walk(j.target)
118
+ }
119
+ for (const id of r.chain ?? []) walk(id)
120
+ if (acc.size > 0) out.set(r.id, [...acc].sort())
121
+ }
122
+ return out
123
+ }
124
+
125
+ /** R1: `store` is assigned only when a store was touched; absent otherwise. */
126
+ function withStore<T extends LoaderResponse>(r: T): T {
127
+ const s = collectSnapshot()
128
+ if (s) (r as { store?: StoreSnapshot | null }).store = s
129
+ return r
130
+ }
131
+
132
+ // ---- handlers ----
133
+
134
+ /** `parent`: the data the chain merged before the verdict. A notFound renders the route's
135
+ * template (layouts included), so the layouts' data stays under the verdict's own keys. */
136
+ function verdictJson(v: Verdict, parent: Record<string, unknown>): LoaderResponse {
137
+ if (v.status === 404) {
138
+ const own = v.data ?? {}
139
+ return { verdict: 'notFound', data: typeof own === 'object' && !Array.isArray(own) ? { ...parent, ...own } : own }
140
+ }
141
+ return { verdict: 'redirect', location: v.headers?.Location ?? '/', status: v.status }
142
+ }
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
+
177
+ export interface Handlers {
178
+ loader(req: LoaderRequest): Promise<LoaderResponse>
179
+ jobs(req: JobsRequest): Promise<JobsResponse>
180
+ }
181
+
182
+ /** `leaves` from `flattenRoutes` (ids must match the manifest's route ids); `jobs` the jobs
183
+ * module; `manifest` supplies ssr `literals` (omit → no merge). */
184
+ export function makeHandlers(opts: {
185
+ leaves: FlatRoute[]
186
+ jobs: JobsModule
187
+ manifest?: ManifestLike
188
+ /** `jobs.js` `stores` table (R2): C3 path -> module namespace. */
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
192
+ }): Handlers {
193
+ const byId = new Map(opts.leaves.map((l) => [l.id, l]))
194
+ const literals = literalsIndex(opts.manifest)
195
+ const jobs = opts.jobs
196
+ const storeIdx = routeStoresIndex(opts.manifest)
197
+ const storeTable = opts.stores ?? {}
198
+
199
+ async function runJob(call: JobCall): Promise<JobResult> {
200
+ const owner = call.kind === 'ssr' ? (call.target ?? call.componentId) : call.componentId // D6
201
+ const fn = jobs[owner]?.[call.kind]
202
+ if (typeof fn !== 'function') return { id: call.id, error: `no ${call.kind} job for component ${owner}` }
203
+ try {
204
+ let props = call.inputs
205
+ if (call.kind === 'ssr') {
206
+ const jid = jobIdOf(call)
207
+ const lit = jid === undefined ? undefined : literals.get(`${call.componentId}/${jid}`)
208
+ if (lit) props = { ...(props ?? {}), ...lit }
209
+ // Precompute runs outside any scope (D8); ssr runs in a scope seeded from `__store`, which props never see.
210
+ const seed = (props?.__store ?? null) as StoreSnapshot | null
211
+ if (props && '__store' in props) {
212
+ const { __store: _, ...rest } = props
213
+ props = rest
214
+ }
215
+ const p = props
216
+ return { id: call.id, value: await runInStoreContext(() => fn(p), seed) }
217
+ }
218
+ return { id: call.id, value: await fn(props) }
219
+ } catch (e) {
220
+ return { id: call.id, error: String(e) }
221
+ }
222
+ }
223
+
224
+ return {
225
+ // 0.1.x `runNativeChainLoaders`: root → leaf, flat merge (later keys win), first verdict stops
226
+ // (a notFound keeps what the chain merged so far: verdictJson).
227
+ async loader(req) {
228
+ const leaf = byId.get(req.routeId)
229
+ if (!leaf) return { error: `unknown routeId ${req.routeId}` }
230
+ // One object for the whole chain: each level's keys are assigned INTO it (later keys win,
231
+ // first-seen key order — what `{...merged, ...r}` produced) instead of re-spreading every
232
+ // key once per level (M3-P P1). `merged` is fresh per call; a loader's own object is never
233
+ // written to. Null prototype: with no `__proto__` setter to hit, an own "__proto__" key in a
234
+ // loader's data is assigned as data, as the spread copied it.
235
+ return runInStoreContext(async () => {
236
+ const merged: Record<string, unknown> = Object.create(null)
237
+ try {
238
+ const touch = storeIdx.get(req.routeId)
239
+ if (touch) touchStores(touch, storeTable)
240
+ for (const node of leaf.chain) {
241
+ if (!node.loader) continue
242
+ let r: unknown
243
+ try {
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 })
246
+ } catch (e) {
247
+ if (isHttpErrorTrigger(e)) return { verdict: 'httpError', status: e.status, body: e.body } as LoaderResponse
248
+ throw e
249
+ }
250
+ if (isVerdict(r)) {
251
+ const v = verdictJson(r, merged)
252
+ return 'verdict' in v && v.verdict === 'notFound' ? withStore(v) : v
253
+ }
254
+ if (r && typeof r === 'object') Object.assign(merged, r)
255
+ }
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
260
+ } catch (e) {
261
+ return { error: String(e) } as LoaderResponse
262
+ }
263
+ })
264
+ },
265
+ // One request carries every job of the page; a per-job failure keeps the other results.
266
+ async jobs(req) {
267
+ return { results: await Promise.all((req.jobs ?? []).map(runJob)) }
268
+ },
269
+ }
270
+ }
271
+
272
+ /** Wraps an async handler for a JSON-string boundary: parses, calls, serialises; NEVER rejects
273
+ * (a parse error or a throwing handler resolves `{"error":…}`, like `makeDispatch`). */
274
+ export function jsonCall<Q, R>(fn: (q: Q) => Promise<R>): (json: string) => Promise<string> {
275
+ return async (json) => {
276
+ try {
277
+ return toJson(await fn(JSON.parse(json)))
278
+ } catch (e) {
279
+ return toJson({ error: String(e) })
280
+ }
281
+ }
282
+ }
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'
package/src/native.ts CHANGED
@@ -22,6 +22,8 @@ export interface StartOptions {
22
22
  workers: number
23
23
  claimTimeoutMs?: number
24
24
  callTimeoutMs?: number
25
+ /** L10: the biggest loader/jobs reply that may cross the heap when it does not fit the slot; absent = 8388608. */
26
+ maxLoaderResponseBytes?: number
25
27
  /** tokio I/O threads; absent = one per core. */
26
28
  workerThreads?: number
27
29
  l1Capacity?: number
@@ -31,6 +33,20 @@ export interface StartOptions {
31
33
  cors?: CorsOptions
32
34
  /** The actions subtree (M3-F A3); absent = no actions. Boot error `actions: …` when invalid. */
33
35
  actions?: ActionsOptions
36
+ /** L16: what the page tells the browser `client()` (base URL, credentials, a non-default prefix); absent = from `actions` alone. */
37
+ actionsClient?: ActionsClientOptions
38
+ /** The cache adapter (`manifest.cache`); absent = none (byte-identical server). */
39
+ l2?: L2Options
40
+ /** `brust dev` only (L14): a request carrying this token (`x-brust-dev-token`) is traced; absent in production. */
41
+ devToken?: string
42
+ }
43
+ /** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
44
+ export interface L2Options {
45
+ adapter: string
46
+ getTimeoutMs?: number
47
+ maxBodyBytes?: number
48
+ namespace: string
49
+ buildId: string
34
50
  }
35
51
  /** napi `NapiCorsOptions` (camelCase keys; snake_case keys are silently dropped). */
36
52
  export interface CorsOptions {
@@ -41,6 +57,12 @@ export interface CorsOptions {
41
57
  credentials?: boolean
42
58
  maxAgeSeconds?: number
43
59
  }
60
+ /** napi `NapiActionsClient`. */
61
+ export interface ActionsClientOptions {
62
+ prefix?: string
63
+ baseUrl?: string
64
+ credentials?: 'same-origin' | 'include' | 'omit'
65
+ }
44
66
  /** napi `NapiActionsOptions`; defaults `/_brust/action`, 1048576, 8388608. */
45
67
  export interface ActionsOptions {
46
68
  prefix?: string
@@ -50,7 +72,7 @@ export interface ActionsOptions {
50
72
  /** F87: cross-site mutating requests. Default `reject`. */
51
73
  crossSite?: 'reject' | 'allow'
52
74
  }
53
- export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch', requestJson: string, slot: number) => Promise<number>
75
+ export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch' | 'cache', requestJson: string, slot: number) => Promise<number>
54
76
  export interface NativeBinding {
55
77
  startServer(opts: StartOptions): void
56
78
  registerWorker(buf: Uint8Array, slots: number, f: WorkerFn): number
@@ -61,10 +83,20 @@ export interface NativeBinding {
61
83
  localAddr(): string
62
84
  /** M3-P P6 — see crates/brust-napi/src/server.rs `plan_jobs`. Never throws. */
63
85
  planJobs(slot: number, len: number): string
86
+ /** L10 — `planJobs` for a loader response too large for the slot: the bytes are handed over directly. Never throws. */
87
+ planJobsBuf(slot: number, buf: Uint8Array): string
64
88
  /** F82 — the heap request body of the in-flight `fetch` call, once; null if none. Never throws. */
65
89
  takeFetchBody(slot: number): Uint8Array | null
66
90
  /** F82 — hand Rust a response body too large for the slot; false = declined. Never throws. */
67
91
  putFetchReply(slot: number, body: Uint8Array): boolean
92
+ /** cache adapter R10 — a foreign broadcast event: tier 1 + the floor at `seq`; never re-published. */
93
+ cacheReplay(args: InvalidateArgs, seq: number | null): InvalidateResult
94
+ /** cache adapter R9 — bump `stats.l2[name]`; false = unknown name / no adapter / no server. Never throws. */
95
+ cacheL2Count(name: string): boolean
96
+ /** cache adapter R10 — drop both tier-1 caches (a broadcast gap); the L1 entries removed. */
97
+ cacheClear(): number
98
+ /** Static output (gaps round R9.4): the request reads of a cache key expression; throws when it does not parse. */
99
+ cacheExprReads(src: string): string[]
68
100
  }
69
101
 
70
102
  let binding: NativeBinding | null = null
@@ -91,5 +123,10 @@ export const cacheInvalidate = (args: InvalidateArgs): InvalidateResult => nativ
91
123
  export const cacheStats = (): string => native().cacheStats()
92
124
  export const localAddr = (): string => native().localAddr()
93
125
  export const planJobs = (slot: number, len: number): string => native().planJobs(slot, len)
126
+ export const planJobsBuf = (slot: number, buf: Uint8Array): string => native().planJobsBuf(slot, buf)
94
127
  export const takeFetchBody = (slot: number): Uint8Array | null => native().takeFetchBody(slot)
95
128
  export const putFetchReply = (slot: number, body: Uint8Array): boolean => native().putFetchReply(slot, body)
129
+ export const cacheReplay = (args: InvalidateArgs, seq: number | null): InvalidateResult => native().cacheReplay(args, seq)
130
+ export const cacheL2Count = (name: string): boolean => native().cacheL2Count(name)
131
+ export const cacheClear = (): number => native().cacheClear()
132
+ export const cacheExprReads = (src: string): string[] => native().cacheExprReads(src)
package/src/routes.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./css-modules.d.ts" />
1
2
  // Route tree, ids and loader verdicts (spec S4, S7 step 4). M2 routes carry ONLY
2
3
  // `path/Component/loader/cache/children`; every other 0.1.x field fails loudly (M3).
3
4
 
@@ -29,15 +30,21 @@ export interface LoaderCtx<P = Record<string, string>> {
29
30
  req: LoaderReq
30
31
  }
31
32
 
33
+ /** `brust build --target static` (gaps round R9.2): `params` lists the values of a dynamic route's
34
+ * `{name}` segments, one record per page; `false` leaves the route out of the static output. The
35
+ * server and Cloudflare targets ignore it. */
36
+ export type RouteSsgConfig<P = Record<string, string>> = { params?: () => P[] | Promise<P[]> } | false
37
+
32
38
  export interface Route<P = Record<string, string>, D = unknown> {
33
39
  path?: string
34
40
  Component?: ComponentType<any>
35
41
  loader?: (ctx: LoaderCtx<P>) => Promise<D> | D
36
42
  cache?: RouteCacheConfig
43
+ ssg?: RouteSsgConfig<P>
37
44
  children?: Route<any, any>[]
38
45
  }
39
46
 
40
- export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'children'] as const
47
+ export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'ssg', 'children'] as const
41
48
  const ALLOWED_CACHE_FIELDS = ['ttl_seconds', 'prefix', 'bypass', 'tags'] as const
42
49
  const M3_CACHE_FIELDS = ['key', 'key_ttl_seconds'] as const
43
50
 
@@ -61,6 +68,18 @@ function validate(node: Route, where: string): void {
61
68
  if (node.path !== undefined && typeof node.path !== 'string') throw new BrustRouteError(`route ${where}: path must be a string`)
62
69
  if (node.loader !== undefined && typeof node.loader !== 'function')
63
70
  throw new BrustRouteError(`route ${where}: loader must be a function`)
71
+ if (node.ssg !== undefined) {
72
+ const ssg = node.ssg as unknown
73
+ if (ssg !== false && (typeof ssg !== 'object' || ssg === null || Array.isArray(ssg)))
74
+ throw new BrustRouteError(`route ${where}: ssg must be false or { params }`)
75
+ if (ssg !== false) {
76
+ for (const k of Object.keys(ssg as object))
77
+ if (k !== 'params') throw new BrustRouteError(`route ${where}: unknown ssg field ssg.${k} (allowed: params)`)
78
+ const params = (ssg as { params?: unknown }).params
79
+ if (params !== undefined && typeof params !== 'function') throw new BrustRouteError(`route ${where}: ssg.params must be a function`)
80
+ }
81
+ if (node.children !== undefined) throw new BrustRouteError(`route ${where}: ssg on a layout route is not supported (put it on each leaf)`)
82
+ }
64
83
  if (node.cache !== undefined) {
65
84
  if (typeof node.cache !== 'object' || node.cache === null) throw new BrustRouteError(`route ${where}: cache must be an object`)
66
85
  for (const k of Object.keys(node.cache)) {
package/src/run.ts CHANGED
@@ -8,38 +8,59 @@
8
8
  // does not end it, so every exit is an explicit `process.exit`.
9
9
  import { existsSync, readFileSync } from 'node:fs'
10
10
  import { join, resolve } from 'node:path'
11
- import { type BrustConfig, loadConfig } from './config'
12
- import { beginDrain, type StartOptions, startServer, untilReady } from './native'
13
- import { SLOT_BYTES } from './worker'
11
+ import { type BrustConfig, type ConfigOverrides, loadConfig } from './config'
12
+ import { beginDrain, localAddr, type StartOptions, startServer, untilReady } from './native'
13
+ import { DEFAULT_MAX_LOADER_RESPONSE_BYTES } from './worker'
14
14
 
15
15
  export interface RunOptions {
16
16
  distDir?: string
17
17
  /** The routes module the workers import (`export const routes` or default). */
18
18
  entry?: string
19
19
  /** Overrides below env (CLI flags): see config.ts (incl. `bootTimeoutMs`, default 30000). */
20
- config?: Partial<BrustConfig>
20
+ config?: ConfigOverrides
21
+ /** Static output (gaps round L9): runs once every worker is ready, with the bound `host:port`;
22
+ * then the server drains and the process exits with the returned code. */
23
+ onReady?: (addr: string) => Promise<number>
21
24
  }
22
25
 
23
26
  /** The manifest fields `run` needs (build/manifest.ts owns the full shape). */
24
27
  export interface RunManifest {
25
28
  actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null
29
+ /** L16 R16.3: `brust build --actions-only`: no page routes and no routes entry. */
30
+ actions_only?: boolean
31
+ /** L16 R16.4b: a page-only build; the actions app is deployed elsewhere (`[actions] base_url`). */
32
+ actions_remote?: { prefix: string } | null
33
+ /** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
34
+ cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string } | null
26
35
  }
27
36
 
28
- /** Pure: the napi `StartOptions`. No actions/cors → exactly the pre-actions keys. */
29
- export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string): StartOptions {
37
+ /** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
38
+ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string): StartOptions {
30
39
  return {
31
40
  host: cfg.host,
32
41
  port: cfg.port,
33
42
  distDir,
34
43
  workers: cfg.workers,
35
44
  callTimeoutMs: cfg.callTimeoutMs,
45
+ ...(cfg.maxLoaderResponseBytes !== undefined && cfg.maxLoaderResponseBytes !== DEFAULT_MAX_LOADER_RESPONSE_BYTES ? { maxLoaderResponseBytes: cfg.maxLoaderResponseBytes } : {}),
36
46
  workerThreads: cfg.ioThreads,
37
47
  generator: `brust/${version}`,
38
48
  ...(m.actions ? { actions: { prefix: m.actions.prefix, maxBodyBytes: m.actions.maxBodyBytes, ...(m.actions.maxResponseBytes !== undefined ? { maxResponseBytes: m.actions.maxResponseBytes } : {}), ...(m.actions.crossSite ? { crossSite: m.actions.crossSite } : {}) } } : {}),
49
+ ...(actionsClient(cfg, m) ? { actionsClient: actionsClient(cfg, m) } : {}),
39
50
  ...(cfg.cors ? { cors: cfg.cors } : {}),
51
+ ...(devToken ? { devToken } : {}),
52
+ ...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
40
53
  }
41
54
  }
42
55
 
56
+ /** L16: what the page tells the browser `client()` (R16.1/R16.2); absent while it would only repeat the prefix script. */
57
+ function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptions['actionsClient']> | undefined {
58
+ const credentials = cfg.actionsCredentials !== undefined && cfg.actionsCredentials !== 'same-origin' ? cfg.actionsCredentials : undefined
59
+ if (cfg.actionsBaseUrl === undefined && credentials === undefined && !m.actions_remote) return undefined
60
+ const prefix = m.actions?.prefix ?? m.actions_remote?.prefix
61
+ return { ...(prefix ? { prefix } : {}), ...(cfg.actionsBaseUrl !== undefined ? { baseUrl: cfg.actionsBaseUrl } : {}), ...(credentials ? { credentials } : {}) }
62
+ }
63
+
43
64
  /** Every worker's response buffer, kept reachable until the process exits (see run()). */
44
65
  const workerBuffers: SharedArrayBuffer[] = []
45
66
 
@@ -60,7 +81,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
60
81
  fail(`bad manifest ${manifest}: ${(e as Error).message}`)
61
82
  }
62
83
  const entry = resolve(process.env.BRUST_APP_ENTRY || opts.entry || 'routes.tsx')
63
- if (!existsSync(entry)) fail(`routes entry ${entry} not found`)
84
+ // R16.3: an actions-only dist has no pages, so no routes module either.
85
+ if (!m.actions_only && !existsSync(entry)) fail(`routes entry ${entry} not found`)
64
86
 
65
87
  let cfg: BrustConfig
66
88
  try {
@@ -69,9 +91,11 @@ export async function run(opts: RunOptions = {}): Promise<void> {
69
91
  fail((e as Error).message)
70
92
  }
71
93
 
94
+ if (m.actions_remote && cfg.actionsBaseUrl === undefined)
95
+ fail('this build has no local actions app: set [actions] base_url (or BRUST_ACTIONS_BASE_URL) to the deployment that serves them')
72
96
  const version: string = (await Bun.file(join(import.meta.dir, '../package.json')).json()).version
73
97
  try {
74
- startServer(startOptions(cfg, distDir, m, version))
98
+ startServer(startOptions(cfg, distDir, m, version, process.env.BRUST_DEV_TOKEN || undefined))
75
99
  } catch (e) {
76
100
  fail((e as Error).message)
77
101
  }
@@ -84,8 +108,12 @@ export async function run(opts: RunOptions = {}): Promise<void> {
84
108
  ...process.env,
85
109
  NODE_ENV: process.env.NODE_ENV,
86
110
  BRUST_RENDER_SLOTS: String(cfg.renderSlots),
111
+ BRUST_SLOT_BYTES: String(cfg.slotBytes),
112
+ BRUST_MAX_LOADER_RESPONSE_BYTES: String(cfg.maxLoaderResponseBytes),
87
113
  BRUST_DIST_DIR: distDir,
88
114
  BRUST_APP_ENTRY: entry,
115
+ // One cache-adapter origin per process: its workers' published events are not replayed here.
116
+ BRUST_CACHE_ORIGIN: crypto.randomUUID(),
89
117
  }
90
118
 
91
119
  let ready = false
@@ -110,7 +138,7 @@ export async function run(opts: RunOptions = {}): Promise<void> {
110
138
  for (let i = 0; i < cfg.workers; i++) {
111
139
  // The response buffer is owned HERE, for the process lifetime: the Rust dispatcher keeps a raw
112
140
  // pointer into it, so it must not be freed with the worker (crates/brust-napi dispatch.rs).
113
- const sab = new SharedArrayBuffer(SLOT_BYTES * cfg.renderSlots)
141
+ const sab = new SharedArrayBuffer(cfg.slotBytes * cfg.renderSlots)
114
142
  workerBuffers.push(sab)
115
143
  const w = new Worker(url, { env: { ...env, BRUST_WORKER_ID: String(i) }, workerData: { sab } } as WorkerOptions)
116
144
  // No respawn in M2: a worker that fails to boot or dies later takes the process down loudly
@@ -153,5 +181,14 @@ export async function run(opts: RunOptions = {}): Promise<void> {
153
181
  }
154
182
  ready = true
155
183
  console.log(`[brust] ready (${cfg.workers} worker${cfg.workers === 1 ? '' : 's'})`)
184
+ if (opts.onReady) {
185
+ let code = 1
186
+ try {
187
+ code = await opts.onReady(localAddr())
188
+ } catch (e) {
189
+ console.error(`[brust] ${(e as Error).stack ?? String(e)}`)
190
+ }
191
+ if (!draining) drainThenExit(code)
192
+ }
156
193
  await new Promise<never>(() => {})
157
194
  }
@@ -0,0 +1,32 @@
1
+ // What the generated Cloudflare entry (`brust-app.js`) imports: the host-agnostic handler code and the
2
+ // store-scoped action fetch. No Bun / worker_threads here; `actions-bridge`'s napi import is stubbed by the
3
+ // CF bundle (M4-CF R8).
4
+ import { type CfCacheBridge, type CfInstallExt, installCache } from './cache-adapter/workers'
5
+ import type { InvalidateArgs, InvalidateResult } from './native'
6
+
7
+ export { fetchInScope, noActions } from './actions-bridge'
8
+ export { jsonCall, makeHandlers } from './handlers'
9
+ export { flattenRoutes } from './routes'
10
+
11
+ /** What the wasm side hands over at start (`#[event(start]` -> `install({ invalidate, l2Count })`, ruling 19753649). */
12
+ export interface CfRuntime {
13
+ invalidate(args: InvalidateArgs): InvalidateResult
14
+ /** Bumps a `stats.l2` counter (cache adapters R9). */
15
+ l2Count?(name: string): boolean
16
+ }
17
+ export type { CfCacheBridge, CfInstallExt }
18
+
19
+ let rt: CfRuntime | null = null
20
+
21
+ /** Called by the wasm glue once at Worker start; the CF bundle's napi stub forwards `cacheInvalidate` to it.
22
+ * With a cache adapter (`ext`, from the generated entry) it answers the adapter bridge (R11, R14). */
23
+ export function install(runtime: CfRuntime, ext?: CfInstallExt): { cache: CfCacheBridge | null } {
24
+ rt = runtime
25
+ return { cache: ext ? installCache(runtime, ext) : null }
26
+ }
27
+
28
+ /** The installed runtime; throws before `install` (a request cannot arrive earlier). */
29
+ export function installedRuntime(): CfRuntime {
30
+ if (!rt) throw new Error('brust: the Cloudflare runtime is not installed (install(rt) was not called at start)')
31
+ return rt
32
+ }