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

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 +82 -4
  3. package/native/index.js +58 -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 +82 -1
  8. package/src/actions/prefix.ts +44 -2
  9. package/src/actions-bridge.ts +44 -9
  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 +9 -4
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +34 -13
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +138 -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 +94 -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 +378 -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 +256 -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 +116 -10
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +32 -1
  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 +65 -13
  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 +105 -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.31",
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.31",
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.31",
104
+ "@brust/native-darwin-arm64": "2.0.0-alpha.31",
105
+ "@brust/native-linux-x64-gnu": "2.0.0-alpha.31",
106
+ "@brust/native-linux-arm64-gnu": "2.0.0-alpha.31",
107
+ "@brust/native-linux-x64-musl": "2.0.0-alpha.31",
108
+ "@brust/native-linux-arm64-musl": "2.0.0-alpha.31",
109
+ "@brust/core-cf": "2.0.0-alpha.31"
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,90 @@ 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
+ }
46
+
47
+ /** F475: the route parses nothing (`{ parse: 'none' }`): Elysia 2 keeps `'none'`, Elysia 1 kept `[{ fn: 'none' }]`. */
48
+ function parsesNone(r: { hooks?: unknown }): boolean {
49
+ const p = (r.hooks as { parse?: unknown } | undefined)?.parse
50
+ const none = (x: unknown) => x === 'none' || (x as { fn?: unknown } | null)?.fn === 'none'
51
+ return Array.isArray(p) ? p.some(none) : none(p)
52
+ }
53
+
54
+ /** Same route path: segments equal, a parameter (`:x`, `{x}`) matches a parameter, `*` matches `*`. */
55
+ function samePath(a: string, b: string): boolean {
56
+ const x = a.split('/')
57
+ const y = b.split('/')
58
+ const param = (s: string) => /^(:|\{)/.test(s)
59
+ return x.length === y.length && x.every((s, i) => s === y[i] || (param(s) && param(y[i]!)))
60
+ }
61
+
62
+ const STREAM_METHODS = new Set(['POST', 'PUT', 'PATCH'])
63
+ /** A route answers `method`: its own, or every method (Elysia 2's `.all()` registers `*`; `ALL` too). */
64
+ const answers = (route: string, method: string) => route === method || route === 'ALL' || route === '*'
65
+
66
+ /** Could one request path match both `listed` (a stream entry, prefix included) and `route` (an Elysia path)? A
67
+ * parameter on either side matches any one segment; a final `*` on either side matches one or more. */
68
+ function overlaps(listed: string, route: string): boolean {
69
+ const a = listed.split('/')
70
+ const b = route.split('/')
71
+ const param = (s: string) => /^(:|\{)/.test(s)
72
+ for (let i = 0; ; i++) {
73
+ const x = a[i]
74
+ const y = b[i]
75
+ if (x === undefined || y === undefined) return x === y
76
+ if ((x === '*' && i === a.length - 1) || (y === '*' && i === b.length - 1)) return x !== '' && y !== ''
77
+ if (x !== y && !param(x) && !param(y)) return false
78
+ }
79
+ }
80
+
81
+ /**
82
+ * F475: `[actions] stream` against the app. `missing`: listed entries with no `{ parse: 'none' }` route of that method and
83
+ * path. `parsed`: routes that parse their body although a listed entry can match their path (a static route under a
84
+ * `:param` or `*` entry). Both fail boot: Rust would stream those bodies up to `max_stream_bytes` into a route that parses
85
+ * them whole, past `max_body_bytes`. `unlisted`: `{ parse: 'none' }` POST/PUT/PATCH routes not on the list (their bodies
86
+ * stay buffered, capped at `max_body_bytes`).
87
+ */
88
+ export function streamRouteCheck(app: AnyElysia, entries: string[], prefix: string): { missing: string[]; parsed: string[]; unlisted: string[] } {
89
+ const full = (p: string) => (prefix === '/' ? p : p === '/' ? prefix : prefix + p)
90
+ const listed = entries.map((e) => {
91
+ const [m, p] = e.trim().split(/ +/)
92
+ return { entry: e, method: m!.toUpperCase(), path: full(p!) }
93
+ })
94
+ const routes = app.routes.map((r) => ({ method: r.method.toUpperCase(), path: r.path, none: parsesNone(r) }))
95
+ const missing = listed.filter((l) => !routes.some((r) => r.none && answers(r.method, l.method) && samePath(r.path, l.path))).map((l) => l.entry)
96
+ const parsed = routes
97
+ .filter((r) => !r.none && listed.some((l) => answers(r.method, l.method) && overlaps(l.path, r.path)))
98
+ .map((r) => `${r.method} ${r.path}`)
99
+ const unlisted = routes
100
+ .filter((r) => r.none && (STREAM_METHODS.has(r.method) || answers(r.method, '')) && !listed.some((l) => answers(r.method, l.method) && samePath(r.path, l.path)))
101
+ .map((r) => `${r.method} ${r.path}`)
102
+ return { missing, parsed, unlisted }
103
+ }
@@ -3,10 +3,17 @@ export const DEFAULT_ACTION_PREFIX = '/_brust/action'
3
3
  export const DEFAULT_MAX_BODY_BYTES = 1_048_576
4
4
  export const DEFAULT_MAX_RESPONSE_BYTES = 8_388_608
5
5
  export const MAX_ACTION_BYTES = 67_108_864
6
+ /** F475: `max_stream_bytes` default (256 MiB) and hard max (4 GiB); `stream_idle_ms` / `stream_total_ms` defaults. */
7
+ export const DEFAULT_MAX_STREAM_BYTES = 268_435_456
8
+ export const MAX_STREAM_BYTES = 4_294_967_296
9
+ export const DEFAULT_STREAM_IDLE_MS = 30_000
10
+ export const DEFAULT_STREAM_TOTAL_MS = 900_000
6
11
  const SEG = /^[A-Za-z0-9._~-]+$/
7
12
 
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 {
13
+ /** Why `p` cannot be the actions prefix, or null. Segment-safe like the server's match. `root`: an actions-only
14
+ * build (L16 R16.4) may mount at `/`; a normal build never may (its page routes would be unreachable). */
15
+ export function prefixError(p: unknown, root = false): string | null {
16
+ if (root && p === '/') return null
10
17
  if (typeof p !== 'string' || !p.startsWith('/') || p.length < 2 || p.endsWith('/'))
11
18
  return `must start with '/', be longer than '/' and have no trailing '/' (got ${JSON.stringify(p)})`
12
19
  const segs = p.slice(1).split('/')
@@ -21,3 +28,38 @@ export function prefixError(p: unknown): string | null {
21
28
  return `${p} is reserved for the server's own /_brust/ paths`
22
29
  return null
23
30
  }
31
+
32
+ /** L16 R16.1: `base_url` is an absolute http(s) origin: scheme, host, optional port; no path, query, fragment or userinfo. */
33
+ export function baseUrlError(v: unknown): string | null {
34
+ if (typeof v !== 'string' || !/^https?:\/\//.test(v))
35
+ return `must be an absolute http(s) origin such as https://api.example.com (got ${JSON.stringify(v)})`
36
+ let u: URL
37
+ try {
38
+ u = new URL(v)
39
+ } catch {
40
+ return `is not a valid URL (got ${JSON.stringify(v)})`
41
+ }
42
+ const authority = v.replace(/^https?:\/\//, '')
43
+ if (u.search !== '' || u.hash !== '' || u.username !== '' || u.password !== '' || /[/?#@\\\s]/.test(authority))
44
+ return `must be an origin only: no path, query, fragment or credentials (got ${JSON.stringify(v)})`
45
+ return null
46
+ }
47
+
48
+ /** L16 R16.2: the default `RequestInit.credentials` of the browser client. */
49
+ export const ACTION_CREDENTIALS = ['same-origin', 'include', 'omit'] as const
50
+ export type ActionCredentials = (typeof ACTION_CREDENTIALS)[number]
51
+
52
+ /** F475: why `e` is not a `[actions] stream` entry (`"POST /upload"`, path relative to the prefix, `:name` one segment,
53
+ * a final `*` the rest), or null. Mirrors Rust `StreamRoute::parse`. */
54
+ export function streamEntryError(e: unknown): string | null {
55
+ if (typeof e !== 'string') return `must be a string "<METHOD> <path>" (got ${JSON.stringify(e)})`
56
+ const m = /^\s*(\S+) +(\S+)\s*$/.exec(e)
57
+ if (!m) return `must be "<METHOD> <path>" (got ${JSON.stringify(e)})`
58
+ if (!['POST', 'PUT', 'PATCH'].includes(m[1]!.toUpperCase())) return `method must be POST, PUT or PATCH (got ${JSON.stringify(e)})`
59
+ const path = m[2]!
60
+ const segs = path.split('/').slice(1)
61
+ const badStar = segs.some((s, i) => s.includes('*') && (s !== '*' || i !== segs.length - 1))
62
+ if (!path.startsWith('/') || (path !== '/' && segs.some((s) => s === '')) || badStar)
63
+ return `path must start with '/', have no empty segment, and use '*' only as the last segment (got ${JSON.stringify(e)})`
64
+ return null
65
+ }
@@ -1,7 +1,7 @@
1
1
  // The worker side of the `fetch` call kind (design E4/E5, contracts A1/A2). Rust forwards the actions prefix subtree
2
2
  // here; the app registered with `setActionsApp` (Elysia 2 via brust() — m3f-actions-elysia) answers a web Request
3
3
  // with a web Response, framed into this worker's SAB slot. No Elysia import: the app is anything with `fetch`.
4
- import { putFetchReply, takeFetchBody } from './native'
4
+ import { pullFetchBody, putFetchReply, takeFetchBody } from './native'
5
5
  import type { LoaderReq } from './routes'
6
6
  import { runInStoreContext } from './store/server-context'
7
7
 
@@ -22,8 +22,10 @@ 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)`. */
26
- body: { text: string } | { b64: string } | { heap: number } | null
25
+ /** `{heap: n}` (F82): more than 262144 bytes (`ACTION_INLINE_BODY_BYTES`, independent of the slot size), fetched with `takeFetchBody(slot)`.
26
+ * `{stream}` (F475): a `[actions] stream` route's body, pulled chunk by chunk with `pullFetchBody(slot, token)`; `length` is the
27
+ * declared Content-Length, if any. */
28
+ body: { text: string } | { b64: string } | { heap: number } | { stream: { length: number | null; token: number } } | null
27
29
  req: LoaderReq
28
30
  }
29
31
  /** A2 meta: `status`, `headers`, and (F82) `heap` — the byte length of a response body that crossed by
@@ -39,8 +41,36 @@ export interface FetchMeta {
39
41
  export interface HeapIo {
40
42
  takeBody(slot: number): Uint8Array | null
41
43
  putReply(slot: number, body: Uint8Array): boolean
44
+ /** F475: the next chunk of the slot's streamed body (`pullFetchBody`); only `fetch` calls with A1 `{stream}` use it. */
45
+ pullBody?(slot: number, token: number): Promise<Uint8Array | string | null>
46
+ }
47
+ const nativeHeapIo: HeapIo = {
48
+ takeBody: (slot) => takeFetchBody(slot),
49
+ putReply: (slot, body) => putFetchReply(slot, body),
50
+ pullBody: (slot, token) => pullFetchBody(slot, token),
51
+ }
52
+
53
+ /** F475: what a failed pull means to the app; Rust answers 413 / 408 / 400 whatever the app then returns. */
54
+ const STREAM_FAILED: Record<string, string> = {
55
+ max_stream_bytes: 'brust: the request body exceeds max_stream_bytes',
56
+ idle: 'brust: the request body stalled longer than stream_idle_ms',
57
+ }
58
+
59
+ /** F475: the request body as the app reads it: each read pulls one chunk from Rust, and nothing is pulled before a read
60
+ * (`highWaterMark: 0`), so the client is read only as fast as the app consumes. */
61
+ export function pulledBody(pull: () => Promise<Uint8Array | string | null>): ReadableStream<Uint8Array> {
62
+ return new ReadableStream<Uint8Array>(
63
+ {
64
+ async pull(c) {
65
+ const r = await pull()
66
+ if (r === null) c.close()
67
+ else if (typeof r === 'string') c.error(new TypeError(STREAM_FAILED[r] ?? 'brust: the request body broke off or ended short'))
68
+ else c.enqueue(r)
69
+ },
70
+ },
71
+ { highWaterMark: 0 },
72
+ )
42
73
  }
43
- const nativeHeapIo: HeapIo = { takeBody: (slot) => takeFetchBody(slot), putReply: (slot, body) => putFetchReply(slot, body) }
44
74
 
45
75
  let maxResponseBytes = 8_388_608
46
76
  /** `startWorker` sets the manifest's `actions.maxResponseBytes` (R5). Default 8388608. */
@@ -68,13 +98,13 @@ const TEXT_PLAIN: [string, string][] = [['content-type', 'text/plain; charset=ut
68
98
 
69
99
  /** A1 → web Request (R8 origin: `http://<host>` when the host pair is sane, else `localhost`). Bytes, never a
70
100
  * string body, so no content-type is invented. GET/HEAD never carry a body (R4; `new Request` forbids one). */
71
- export function toRequest(r: FetchRequest, heapBody?: Uint8Array): Request {
101
+ export function toRequest(r: FetchRequest, heapBody?: Uint8Array | ReadableStream<Uint8Array>): Request {
72
102
  const host = r.headers.find(([k]) => k === 'host')?.[1]
73
103
  const origin = `http://${host && HOST.test(host) ? host : 'localhost'}`
74
104
  const bodyless = r.method === 'GET' || r.method === 'HEAD'
75
105
  const b = r.body
76
- const body = bodyless || !b ? undefined : 'heap' in b ? (heapBody as BodyInit | undefined) : 'text' in b ? enc.encode(b.text) : Buffer.from(b.b64, 'base64')
77
- return new Request(origin + r.url, { method: r.method, headers: r.headers, body })
106
+ const body = bodyless || !b ? undefined : 'heap' in b || 'stream' in b ? (heapBody as BodyInit | undefined) : 'text' in b ? enc.encode(b.text) : Buffer.from(b.b64, 'base64')
107
+ return new Request(origin + r.url, { method: r.method, headers: r.headers, body, ...(body instanceof ReadableStream ? { duplex: 'half' } : {}) } as RequestInit)
78
108
  }
79
109
 
80
110
  /** A2 writer at `slot`'s sub-region `[slot*sub, slot*sub+sub)`, `sub = floor(view.byteLength / slots)`:
@@ -139,18 +169,23 @@ export const noActions = async (_: Request): Promise<Response> =>
139
169
  new Response('brust: actions are not configured in this worker', { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } })
140
170
 
141
171
  /** 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
172
+ * 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
173
  * by `io.putReply` with only status/headers/`heap` in the slot (F82), unless it exceeds `max_response_bytes`. */
144
174
  export async function runFetch(requestJson: string, view: Uint8Array, slot: number, slots: number, io: HeapIo = nativeHeapIo): Promise<number> {
145
175
  const a = app
146
176
  if (!a) return fail(view, slot, slots, 'brust: actions are not configured in this worker')
147
177
  try {
148
178
  const fr = JSON.parse(requestJson) as FetchRequest
149
- let heapBody: Uint8Array | undefined
179
+ let heapBody: Uint8Array | ReadableStream<Uint8Array> | undefined
150
180
  if (fr.body && 'heap' in fr.body) {
151
181
  const got = io.takeBody(slot)
152
182
  if (!got || got.length !== fr.body.heap) throw new Error(`heap request body ${got ? `${got.length} != ${fr.body.heap}` : 'missing'} bytes (F82)`)
153
183
  heapBody = got
184
+ } else if (fr.body && 'stream' in fr.body) {
185
+ const { pullBody } = io
186
+ if (!pullBody) throw new Error('streamed request body without a pull channel (F475)')
187
+ const { token } = fr.body.stream
188
+ heapBody = pulledBody(() => pullBody(slot, token))
154
189
  }
155
190
  const req = toRequest(fr, heapBody)
156
191
  // R1/E5: one store scope around the handler AND the body read (a lazy body may still touch stores).
@@ -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, ...actions.stream },
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
+ }