@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.
- package/README.md +66 -6
- package/native/index.d.ts +82 -4
- package/native/index.js +58 -54
- package/package.json +29 -15
- package/src/actions/client.ts +53 -7
- package/src/actions/elysia.ts +1 -1
- package/src/actions/load.ts +82 -1
- package/src/actions/prefix.ts +44 -2
- package/src/actions-bridge.ts +44 -9
- package/src/assets.d.ts +55 -0
- package/src/browser.ts +6 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +332 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +466 -54
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +9 -4
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +34 -13
- package/src/build/compile.ts +263 -6
- package/src/build/config.ts +138 -11
- package/src/build/css.ts +353 -0
- package/src/build/esm-resolve.ts +170 -0
- package/src/build/features.ts +119 -0
- package/src/build/index.ts +210 -27
- package/src/build/manifest.ts +94 -7
- package/src/build/pkg-src.ts +514 -0
- package/src/build/recognised.ts +19 -0
- package/src/build/scan.ts +71 -20
- package/src/build/server-only.ts +62 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +259 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/types.ts +1 -1
- package/src/cf.ts +4 -1
- package/src/cli.ts +40 -4
- package/src/config.ts +111 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +378 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +358 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +256 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +130 -0
- package/src/dev.ts +15 -28
- package/src/handlers.ts +116 -10
- package/src/index.ts +6 -1
- package/src/native.ts +32 -1
- package/src/navigate-browser.ts +19 -0
- package/src/navigate.ts +19 -0
- package/src/navigation.ts +91 -0
- package/src/routes.ts +31 -1
- package/src/run.ts +65 -13
- package/src/shadcn/init.ts +513 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/shadcn/registry.ts +149 -0
- package/src/store/builtin.ts +4 -0
- 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
|
+
"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.
|
|
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-
|
|
58
|
-
"@elysia/eden": "2.0.0-
|
|
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-
|
|
77
|
+
"@elysia/eden": "2.0.0-rc.0",
|
|
70
78
|
"@napi-rs/cli": "^3.0.0",
|
|
71
|
-
"@
|
|
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-
|
|
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.
|
|
90
|
-
"@brust/native-darwin-arm64": "2.0.0-alpha.
|
|
91
|
-
"@brust/native-linux-x64-gnu": "2.0.0-alpha.
|
|
92
|
-
"@brust/native-linux-arm64-gnu": "2.0.0-alpha.
|
|
93
|
-
"@brust/native-linux-x64-musl": "2.0.0-alpha.
|
|
94
|
-
"@brust/native-linux-arm64-musl": "2.0.0-alpha.
|
|
95
|
-
"@brust/core-cf": "2.0.0-alpha.
|
|
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
|
}
|
package/src/actions/client.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
...(init ? { fetch: init } : {}),
|
|
97
|
+
fetcher: send,
|
|
98
|
+
...(init || defaults ? { fetch: typeof init === 'function' ? init : { ...defaults, ...init } } : {}),
|
|
53
99
|
})
|
|
54
100
|
}
|
package/src/actions/elysia.ts
CHANGED
|
@@ -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-
|
|
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'
|
package/src/actions/load.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/actions/prefix.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/actions-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/src/assets.d.ts
ADDED
|
@@ -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
|
+
}
|