@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.30

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 (64) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +63 -4
  3. package/native/index.js +57 -54
  4. package/package.json +29 -15
  5. package/src/actions/client.ts +53 -7
  6. package/src/actions/elysia.ts +1 -1
  7. package/src/actions/load.ts +24 -1
  8. package/src/actions/prefix.ts +24 -2
  9. package/src/actions-bridge.ts +2 -2
  10. package/src/assets.d.ts +55 -0
  11. package/src/browser.ts +6 -1
  12. package/src/build/actions-only.ts +86 -0
  13. package/src/build/build-eval.ts +332 -0
  14. package/src/build/build-icons.ts +69 -0
  15. package/src/build/bundle.ts +466 -54
  16. package/src/build/cf-assets.ts +4 -2
  17. package/src/build/cf-entry.ts +7 -3
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +21 -11
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +85 -11
  22. package/src/build/css.ts +353 -0
  23. package/src/build/esm-resolve.ts +170 -0
  24. package/src/build/features.ts +119 -0
  25. package/src/build/index.ts +210 -27
  26. package/src/build/manifest.ts +88 -7
  27. package/src/build/pkg-src.ts +514 -0
  28. package/src/build/recognised.ts +19 -0
  29. package/src/build/scan.ts +71 -20
  30. package/src/build/server-only.ts +62 -0
  31. package/src/build/ssg-render.ts +65 -0
  32. package/src/build/ssg.ts +146 -0
  33. package/src/build/static-worker.ts +91 -0
  34. package/src/build/static.ts +259 -0
  35. package/src/build/tailwind.ts +117 -0
  36. package/src/build/tsconfig-paths.ts +67 -0
  37. package/src/build-time.ts +10 -0
  38. package/src/cache-adapter/types.ts +1 -1
  39. package/src/cf.ts +4 -1
  40. package/src/cli.ts +40 -4
  41. package/src/config.ts +111 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +371 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +358 -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 +130 -0
  51. package/src/dev.ts +15 -28
  52. package/src/handlers.ts +115 -9
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +18 -0
  55. package/src/navigate-browser.ts +19 -0
  56. package/src/navigate.ts +19 -0
  57. package/src/navigation.ts +91 -0
  58. package/src/routes.ts +31 -1
  59. package/src/run.ts +53 -11
  60. package/src/shadcn/init.ts +513 -0
  61. package/src/shadcn/jsonc.ts +129 -0
  62. package/src/shadcn/registry.ts +149 -0
  63. package/src/store/builtin.ts +4 -0
  64. package/src/worker.ts +98 -22
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@brust/core",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.30",
4
4
  "description": "brust v2 — React components compiled to Rust-rendered templates",
5
5
  "license": "MIT",
6
+ "homepage": "https://brust.dev",
6
7
  "repository": {
7
8
  "type": "git",
8
- "url": "git+https://github.com/AssetsArt/brust.git"
9
+ "url": "git+https://github.com/AssetsArt/brust.git",
10
+ "directory": "packages/brust"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/AssetsArt/brust/issues"
9
14
  },
10
15
  "type": "module",
11
16
  "bin": {
@@ -17,6 +22,7 @@
17
22
  "browser": "./src/browser.ts",
18
23
  "default": "./src/index.ts"
19
24
  },
25
+ "./build-time": "./src/build-time.ts",
20
26
  "./routes": "./src/routes.ts",
21
27
  "./server": "./src/server.ts",
22
28
  "./server-handlers": "./src/server-handlers.ts",
@@ -25,6 +31,7 @@
25
31
  "./cache-adapter/conformance": "./src/cache-adapter/conformance.ts",
26
32
  "./store": "./src/store/index.ts",
27
33
  "./store/react": "./src/store/react.ts",
34
+ "./navigation": "./src/navigation.ts",
28
35
  "./elysia": "./src/actions/elysia.ts",
29
36
  "./client": "./src/actions/client.ts",
30
37
  "./native": "./native/index.js"
@@ -49,13 +56,14 @@
49
56
  ]
50
57
  },
51
58
  "dependencies": {
52
- "@brust/runtime-dom": "2.0.0-alpha.3"
59
+ "@brust/runtime-dom": "2.0.0-alpha.30",
60
+ "tsconfig-paths": "4.2.0"
53
61
  },
54
62
  "peerDependencies": {
55
63
  "react": "^19.0.0",
56
64
  "react-dom": "^19.0.0",
57
- "elysia": "2.0.0-beta.30",
58
- "@elysia/eden": "2.0.0-beta.6"
65
+ "elysia": "^2.0.0-rc.0",
66
+ "@elysia/eden": "^2.0.0-rc.0"
59
67
  },
60
68
  "peerDependenciesMeta": {
61
69
  "elysia": {
@@ -66,11 +74,17 @@
66
74
  }
67
75
  },
68
76
  "devDependencies": {
69
- "@elysia/eden": "2.0.0-beta.6",
77
+ "@elysia/eden": "2.0.0-rc.0",
70
78
  "@napi-rs/cli": "^3.0.0",
71
- "@types/bun": "^1.4.0",
79
+ "@radix-ui/react-collection": "1.2.0",
80
+ "@radix-ui/react-id": "1.1.4",
81
+ "@radix-ui/react-use-layout-effect": "1.1.4",
82
+ "@radix-ui/react-presence": "1.1.11",
83
+ "@radix-ui/react-slot": "1.5.0",
84
+ "@types/bun": "^1.4.4",
72
85
  "@types/react": "^19.2.0",
73
- "elysia": "2.0.0-beta.30",
86
+ "elysia": "2.0.0-rc.0",
87
+ "lucide-react": "1.55.0",
74
88
  "react": "^19.2.0",
75
89
  "react-dom": "^19.2.0",
76
90
  "typescript": "^7.0.2"
@@ -86,12 +100,12 @@
86
100
  "access": "public"
87
101
  },
88
102
  "optionalDependencies": {
89
- "@brust/native-darwin-x64": "2.0.0-alpha.3",
90
- "@brust/native-darwin-arm64": "2.0.0-alpha.3",
91
- "@brust/native-linux-x64-gnu": "2.0.0-alpha.3",
92
- "@brust/native-linux-arm64-gnu": "2.0.0-alpha.3",
93
- "@brust/native-linux-x64-musl": "2.0.0-alpha.3",
94
- "@brust/native-linux-arm64-musl": "2.0.0-alpha.3",
95
- "@brust/core-cf": "2.0.0-alpha.3"
103
+ "@brust/native-darwin-x64": "2.0.0-alpha.30",
104
+ "@brust/native-darwin-arm64": "2.0.0-alpha.30",
105
+ "@brust/native-linux-x64-gnu": "2.0.0-alpha.30",
106
+ "@brust/native-linux-arm64-gnu": "2.0.0-alpha.30",
107
+ "@brust/native-linux-x64-musl": "2.0.0-alpha.30",
108
+ "@brust/native-linux-arm64-musl": "2.0.0-alpha.30",
109
+ "@brust/core-cf": "2.0.0-alpha.30"
96
110
  }
97
111
  }
@@ -2,11 +2,21 @@
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 { clearNavigationCache } from '../navigate-browser'
6
+ import { type ActionCredentials, DEFAULT_ACTION_PREFIX } from './prefix'
6
7
 
7
8
  export { DEFAULT_ACTION_PREFIX }
8
9
  export type { Treaty }
9
10
 
11
+ /** One link of the request chain (F471). `next` sends `req` (or a request you build from it) to the next link, or to
12
+ * the transport after the last one. Call it zero times (answer yourself), once, or many times (renew a token, then
13
+ * send the same request again). Every `next(r)` sends its own copy of `r`: `r` itself is never consumed, so a
14
+ * retry sends the same body again. Read a body through `req.clone()`. */
15
+ export type ActionMiddleware = (
16
+ req: Request,
17
+ next: (req: Request) => Promise<Response>,
18
+ ) => Promise<Response> | Response
19
+
10
20
  export interface ClientOptions<Head extends Record<string, unknown> = {}>
11
21
  extends Omit<Treaty.Config<Head>, 'keepDomain' | 'fetcher' | 'fetch'> {
12
22
  /** Mount point of the actions app. Default: see resolvePrefix. */
@@ -17,9 +27,22 @@ export interface ClientOptions<Head extends Record<string, unknown> = {}>
17
27
  fetch?: typeof fetch
18
28
  /** RequestInit defaults for every call (Eden's `fetch` option), e.g. `{ credentials: 'include' }`. */
19
29
  init?: Treaty.Config<Head>['fetch']
30
+ /** F471: links between Eden and the transport; `middleware[0]` is the outermost. They see the request Eden built
31
+ * (URL, body, `headers`, `onRequest`) and return the Response Eden parses. A throw is a network failure (503). */
32
+ middleware?: ActionMiddleware[]
33
+ }
34
+
35
+ interface PageGlobals {
36
+ __BRUST_ACTION_PREFIX__?: string
37
+ /** L16 R16.1: injected from `[actions] base_url` / BRUST_ACTIONS_BASE_URL / run({ config: { actionsBaseUrl } }). */
38
+ __BRUST_ACTION_BASE__?: string
39
+ /** L16 R16.2: injected from `[actions] credentials`. */
40
+ __BRUST_ACTION_CREDENTIALS__?: ActionCredentials
20
41
  }
42
+ const page = () => globalThis as PageGlobals
21
43
 
22
- /** 0.1.x runtime/treaty.ts:86-93, verbatim order. */
44
+ /** 0.1.x runtime/treaty.ts:86-93, verbatim order. A per-call `baseUrl` keeps the default prefix; a base that comes
45
+ * from the page global (L16) pairs with the page's injected prefix, both being the same deployment's config. */
23
46
  export function resolvePrefix(opts?: { prefix?: string; baseUrl?: string }): string {
24
47
  if (opts?.prefix) return opts.prefix
25
48
  if (opts?.baseUrl !== undefined) return DEFAULT_ACTION_PREFIX
@@ -31,7 +54,8 @@ export function resolvePrefix(opts?: { prefix?: string; baseUrl?: string }): str
31
54
 
32
55
  /** 0.1.x runtime/treaty.ts:95-106; message names `client`. */
33
56
  export function resolveBaseUrl(opts?: { baseUrl?: string }): string {
34
- const b = opts?.baseUrl
57
+ // R16.1 order: opts.baseUrl, the page's __BRUST_ACTION_BASE__, relative.
58
+ const b = opts?.baseUrl ?? page().__BRUST_ACTION_BASE__
35
59
  if (b === undefined) return ''
36
60
  if (!/^https?:\/\//.test(b))
37
61
  throw new Error(
@@ -43,12 +67,34 @@ export function resolveBaseUrl(opts?: { baseUrl?: string }): string {
43
67
  export function client<const App extends AnyElysia, Head extends Record<string, unknown> = {}>(
44
68
  opts: ClientOptions<Head> = {},
45
69
  ): Treaty.Instance<App, Head> {
46
- const { prefix: _p, baseUrl: _b, fetch: fetcher, init, ...rest } = opts
70
+ const { prefix: _p, baseUrl: _b, fetch: fetcher, init, middleware, ...rest } = opts
71
+ // R16.2: the page's credentials default (`opts.init` wins); only when it differs from the browser default.
72
+ const creds = page().__BRUST_ACTION_CREDENTIALS__
73
+ const defaults = creds !== undefined && creds !== 'same-origin' && typeof init !== 'function' ? { credentials: creds } : undefined
47
74
  // keepDomain: Eden would rewrite a relative base to `https:///…` (probe kd.ts).
48
- return treaty<App, Head>(resolveBaseUrl(opts) + resolvePrefix(opts), {
75
+ // The root prefix (an actions-only deployment) adds nothing to the base: `https://api.example.com/team`, not `//team`.
76
+ const base = resolveBaseUrl(opts)
77
+ const prefix = resolvePrefix(opts)
78
+ // SPA navigation design §10.1: a mutation clears the page cache, so no page read after it is served from before it
79
+ const transport = async (url: RequestInfo | URL, req?: RequestInit) => {
80
+ const res = await (fetcher ?? fetch)(url, req)
81
+ if (!/^(GET|HEAD)$/i.test(req?.method ?? (url instanceof Request ? url.method : 'GET'))) clearNavigationCache()
82
+ return res
83
+ }
84
+ // F471: Eden calls `send(url, init)`; the chain works on a Request. `next` clones what it is given, so the caller's
85
+ // request stays unread and can be sent again.
86
+ const chain = middleware?.length ? middleware : undefined
87
+ const send = (chain
88
+ ? async (url: RequestInfo | URL, init?: RequestInit) => {
89
+ const run = (i: number, req: Request): Promise<Response> =>
90
+ i < chain.length ? Promise.resolve(chain[i]!(req, (r) => run(i + 1, r.clone()))) : transport(req)
91
+ return run(0, new Request(url, init))
92
+ }
93
+ : transport) as typeof fetch
94
+ return treaty<App, Head>(base !== '' && prefix === '/' ? base : base + prefix, {
49
95
  ...rest,
50
96
  keepDomain: true,
51
- ...(fetcher ? { fetcher } : {}),
52
- ...(init ? { fetch: init } : {}),
97
+ fetcher: send,
98
+ ...(init || defaults ? { fetch: typeof init === 'function' ? init : { ...defaults, ...init } } : {}),
53
99
  })
54
100
  }
@@ -1,5 +1,5 @@
1
1
  // `@brust/core/elysia` (M3-F E2, ruling R2): the adapter an actions app is built with. Rust owns
2
- // HTTP; the worker drives the app with `app.fetch(request)`. Elysia 2.0.0-beta.30's adapter is
2
+ // HTTP; the worker drives the app with `app.fetch(request)`. Elysia 2.0.0-rc.0's adapter is
3
3
  // `{ listen?, parse, response, setup? }` (elysia/adapter/types.d.ts); `name` is brust's own tag.
4
4
  import type { ElysiaAdapter } from 'elysia/adapter'
5
5
  import { WebStandardAdapter } from 'elysia/adapter/web-standard'
@@ -14,9 +14,32 @@ 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> {
21
22
  return mountActions((await import(file)).default, prefix, file)
22
23
  }
24
+
25
+ /** One path segment of each side matches the other: equal, or either is a parameter (`:x`, `{x}`, `*`). */
26
+ const segMatch = (a: string, b: string) => a === b || /^(:|\{|\*)/.test(a) || /^(:|\{|\*)/.test(b)
27
+
28
+ /**
29
+ * F470: the GET actions a page or loader route under the prefix shadows (a GET there is the route's), as
30
+ * `GET <action path> (route <pattern>)`. Best effort, for a boot warning: segments compared, a parameter matches any.
31
+ */
32
+ export function shadowedGetActions(app: AnyElysia, patterns: string[], prefix: string): string[] {
33
+ const under = patterns.filter((p) => p.startsWith(`${prefix}/`))
34
+ const out: string[] = []
35
+ for (const r of app.routes) {
36
+ if (r.method !== 'GET') continue
37
+ const a = r.path.split('/')
38
+ const hit = under.find((p) => {
39
+ const b = p.split('/')
40
+ return a.length === b.length && a.every((s, i) => segMatch(s, b[i]!))
41
+ })
42
+ if (hit) out.push(`GET ${r.path} (route ${hit})`)
43
+ }
44
+ return out
45
+ }
@@ -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
  }
@@ -139,7 +139,7 @@ export const noActions = async (_: Request): Promise<Response> =>
139
139
  new Response('brust: actions are not configured in this worker', { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } })
140
140
 
141
141
  /** The `fetch` call (makeDispatch). Never rejects, never writes JSON: every outcome is a frame. A body of
142
- * 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
143
143
  * by `io.putReply` with only status/headers/`heap` in the slot (F82), unless it exceeds `max_response_bytes`. */
144
144
  export async function runFetch(requestJson: string, view: Uint8Array, slot: number, slots: number, io: HeapIo = nativeHeapIo): Promise<number> {
145
145
  const a = app
@@ -0,0 +1,55 @@
1
+ // F411: an import of a font or an image (`SERVED_ASSET_EXT`) is the served URL of the file, `/_brust/client/<name>-<hash>.<ext>`,
2
+ // the same URL a stylesheet's `url()` of it is rewritten to (the build's `cssPlugin`). Pulled in by a `/// <reference>` from the
3
+ // entries apps import, so `import face from './fonts/face.woff2'` typechecks.
4
+ declare module '*.woff2' {
5
+ const url: string
6
+ export default url
7
+ }
8
+ declare module '*.woff' {
9
+ const url: string
10
+ export default url
11
+ }
12
+ declare module '*.ttf' {
13
+ const url: string
14
+ export default url
15
+ }
16
+ declare module '*.otf' {
17
+ const url: string
18
+ export default url
19
+ }
20
+ declare module '*.eot' {
21
+ const url: string
22
+ export default url
23
+ }
24
+ declare module '*.png' {
25
+ const url: string
26
+ export default url
27
+ }
28
+ declare module '*.jpg' {
29
+ const url: string
30
+ export default url
31
+ }
32
+ declare module '*.jpeg' {
33
+ const url: string
34
+ export default url
35
+ }
36
+ declare module '*.gif' {
37
+ const url: string
38
+ export default url
39
+ }
40
+ declare module '*.webp' {
41
+ const url: string
42
+ export default url
43
+ }
44
+ declare module '*.avif' {
45
+ const url: string
46
+ export default url
47
+ }
48
+ declare module '*.svg' {
49
+ const url: string
50
+ export default url
51
+ }
52
+ declare module '*.ico' {
53
+ const url: string
54
+ export default url
55
+ }
package/src/browser.ts CHANGED
@@ -1,7 +1,12 @@
1
+ /// <reference path="./css-modules.d.ts" />
2
+ /// <reference path="./assets.d.ts" />
1
3
  // `@brust/core` under the `browser` export condition (and what every browser bundle of
2
4
  // `brust build` resolves the package root to): only the side-effect-free pieces. No `run`, no
3
5
  // config, no napi addon — a react island may import `cache` from the package root.
4
6
  import { cacheComponent } from './cache-core'
7
+ export { buildTime } from './build-time'
8
+ export { clearNavigationCache, navigate } from './navigate-browser'
9
+ export type { NavErrorDetail, NavLoadDetail, NavigateOptions, NavSuccessDetail, NavTrigger } from './navigate'
5
10
  import type { InvalidateArgs, InvalidateResult } from './native'
6
11
 
7
12
  function invalidate(_args: InvalidateArgs): InvalidateResult {
@@ -26,4 +31,4 @@ export {
26
31
  Outlet,
27
32
  redirect,
28
33
  } from './routes'
29
- export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
34
+ 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: '', features: {} },
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
+ }