@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11
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 +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -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 +258 -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/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +70 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/worker.ts +129 -245
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.11",
|
|
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": {
|
|
@@ -13,11 +18,17 @@
|
|
|
13
18
|
},
|
|
14
19
|
"exports": {
|
|
15
20
|
".": {
|
|
21
|
+
"workerd": "./src/cf.ts",
|
|
16
22
|
"browser": "./src/browser.ts",
|
|
17
23
|
"default": "./src/index.ts"
|
|
18
24
|
},
|
|
25
|
+
"./build-time": "./src/build-time.ts",
|
|
19
26
|
"./routes": "./src/routes.ts",
|
|
20
27
|
"./server": "./src/server.ts",
|
|
28
|
+
"./server-handlers": "./src/server-handlers.ts",
|
|
29
|
+
"./server-handlers/actions": "./src/actions/load.ts",
|
|
30
|
+
"./cache-adapter": "./src/cache-adapter/index.ts",
|
|
31
|
+
"./cache-adapter/conformance": "./src/cache-adapter/conformance.ts",
|
|
21
32
|
"./store": "./src/store/index.ts",
|
|
22
33
|
"./store/react": "./src/store/react.ts",
|
|
23
34
|
"./elysia": "./src/actions/elysia.ts",
|
|
@@ -44,7 +55,7 @@
|
|
|
44
55
|
]
|
|
45
56
|
},
|
|
46
57
|
"dependencies": {
|
|
47
|
-
"@brust/runtime-dom": "2.0.0-alpha.
|
|
58
|
+
"@brust/runtime-dom": "2.0.0-alpha.11"
|
|
48
59
|
},
|
|
49
60
|
"peerDependencies": {
|
|
50
61
|
"react": "^19.0.0",
|
|
@@ -66,6 +77,7 @@
|
|
|
66
77
|
"@types/bun": "^1.4.0",
|
|
67
78
|
"@types/react": "^19.2.0",
|
|
68
79
|
"elysia": "2.0.0-beta.30",
|
|
80
|
+
"lucide-react": "1.55.0",
|
|
69
81
|
"react": "^19.2.0",
|
|
70
82
|
"react-dom": "^19.2.0",
|
|
71
83
|
"typescript": "^7.0.2"
|
|
@@ -81,11 +93,12 @@
|
|
|
81
93
|
"access": "public"
|
|
82
94
|
},
|
|
83
95
|
"optionalDependencies": {
|
|
84
|
-
"@brust/native-darwin-x64": "2.0.0-alpha.
|
|
85
|
-
"@brust/native-darwin-arm64": "2.0.0-alpha.
|
|
86
|
-
"@brust/native-linux-x64-gnu": "2.0.0-alpha.
|
|
87
|
-
"@brust/native-linux-arm64-gnu": "2.0.0-alpha.
|
|
88
|
-
"@brust/native-linux-x64-musl": "2.0.0-alpha.
|
|
89
|
-
"@brust/native-linux-arm64-musl": "2.0.0-alpha.
|
|
96
|
+
"@brust/native-darwin-x64": "2.0.0-alpha.11",
|
|
97
|
+
"@brust/native-darwin-arm64": "2.0.0-alpha.11",
|
|
98
|
+
"@brust/native-linux-x64-gnu": "2.0.0-alpha.11",
|
|
99
|
+
"@brust/native-linux-arm64-gnu": "2.0.0-alpha.11",
|
|
100
|
+
"@brust/native-linux-x64-musl": "2.0.0-alpha.11",
|
|
101
|
+
"@brust/native-linux-arm64-musl": "2.0.0-alpha.11",
|
|
102
|
+
"@brust/core-cf": "2.0.0-alpha.11"
|
|
90
103
|
}
|
|
91
104
|
}
|
package/src/actions/client.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// elysia is type-only here. Differences from 0.1.x runtime/treaty.ts: docs/actions.md "The client".
|
|
3
3
|
import { type Treaty, treaty } from '@elysia/eden/treaty2'
|
|
4
4
|
import type { AnyElysia } from 'elysia'
|
|
5
|
-
import { DEFAULT_ACTION_PREFIX } from './prefix'
|
|
5
|
+
import { type ActionCredentials, DEFAULT_ACTION_PREFIX } from './prefix'
|
|
6
6
|
|
|
7
7
|
export { DEFAULT_ACTION_PREFIX }
|
|
8
8
|
export type { Treaty }
|
|
@@ -19,7 +19,17 @@ export interface ClientOptions<Head extends Record<string, unknown> = {}>
|
|
|
19
19
|
init?: Treaty.Config<Head>['fetch']
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
interface PageGlobals {
|
|
23
|
+
__BRUST_ACTION_PREFIX__?: string
|
|
24
|
+
/** L16 R16.1: injected from `[actions] base_url` / BRUST_ACTIONS_BASE_URL / run({ config: { actionsBaseUrl } }). */
|
|
25
|
+
__BRUST_ACTION_BASE__?: string
|
|
26
|
+
/** L16 R16.2: injected from `[actions] credentials`. */
|
|
27
|
+
__BRUST_ACTION_CREDENTIALS__?: ActionCredentials
|
|
28
|
+
}
|
|
29
|
+
const page = () => globalThis as PageGlobals
|
|
30
|
+
|
|
31
|
+
/** 0.1.x runtime/treaty.ts:86-93, verbatim order. A per-call `baseUrl` keeps the default prefix; a base that comes
|
|
32
|
+
* from the page global (L16) pairs with the page's injected prefix, both being the same deployment's config. */
|
|
23
33
|
export function resolvePrefix(opts?: { prefix?: string; baseUrl?: string }): string {
|
|
24
34
|
if (opts?.prefix) return opts.prefix
|
|
25
35
|
if (opts?.baseUrl !== undefined) return DEFAULT_ACTION_PREFIX
|
|
@@ -31,7 +41,8 @@ export function resolvePrefix(opts?: { prefix?: string; baseUrl?: string }): str
|
|
|
31
41
|
|
|
32
42
|
/** 0.1.x runtime/treaty.ts:95-106; message names `client`. */
|
|
33
43
|
export function resolveBaseUrl(opts?: { baseUrl?: string }): string {
|
|
34
|
-
|
|
44
|
+
// R16.1 order: opts.baseUrl, the page's __BRUST_ACTION_BASE__, relative.
|
|
45
|
+
const b = opts?.baseUrl ?? page().__BRUST_ACTION_BASE__
|
|
35
46
|
if (b === undefined) return ''
|
|
36
47
|
if (!/^https?:\/\//.test(b))
|
|
37
48
|
throw new Error(
|
|
@@ -44,11 +55,17 @@ export function client<const App extends AnyElysia, Head extends Record<string,
|
|
|
44
55
|
opts: ClientOptions<Head> = {},
|
|
45
56
|
): Treaty.Instance<App, Head> {
|
|
46
57
|
const { prefix: _p, baseUrl: _b, fetch: fetcher, init, ...rest } = opts
|
|
58
|
+
// R16.2: the page's credentials default (`opts.init` wins); only when it differs from the browser default.
|
|
59
|
+
const creds = page().__BRUST_ACTION_CREDENTIALS__
|
|
60
|
+
const defaults = creds !== undefined && creds !== 'same-origin' && typeof init !== 'function' ? { credentials: creds } : undefined
|
|
47
61
|
// keepDomain: Eden would rewrite a relative base to `https:///…` (probe kd.ts).
|
|
48
|
-
|
|
62
|
+
// The root prefix (an actions-only deployment) adds nothing to the base: `https://api.example.com/team`, not `//team`.
|
|
63
|
+
const base = resolveBaseUrl(opts)
|
|
64
|
+
const prefix = resolvePrefix(opts)
|
|
65
|
+
return treaty<App, Head>(base !== '' && prefix === '/' ? base : base + prefix, {
|
|
49
66
|
...rest,
|
|
50
67
|
keepDomain: true,
|
|
51
68
|
...(fetcher ? { fetcher } : {}),
|
|
52
|
-
...(init ? { fetch: init } : {}),
|
|
69
|
+
...(init || defaults ? { fetch: typeof init === 'function' ? init : { ...defaults, ...init } } : {}),
|
|
53
70
|
})
|
|
54
71
|
}
|
package/src/actions/load.ts
CHANGED
|
@@ -14,7 +14,8 @@ export function mountActions(app: unknown, prefix: string, what = 'actions entry
|
|
|
14
14
|
throw new Error(
|
|
15
15
|
`${what}: build the app with new Elysia({ adapter: brust() }) — import { brust } from '@brust/core/elysia'`,
|
|
16
16
|
)
|
|
17
|
-
|
|
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> {
|
package/src/actions/prefix.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Browser-safe (the client imports it): no elysia import.
|
|
2
2
|
export const DEFAULT_ACTION_PREFIX = '/_brust/action'
|
|
3
|
-
export const DEFAULT_MAX_BODY_BYTES =
|
|
3
|
+
export const DEFAULT_MAX_BODY_BYTES = 1_048_576
|
|
4
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 8_388_608
|
|
5
|
+
export const MAX_ACTION_BYTES = 67_108_864
|
|
4
6
|
const SEG = /^[A-Za-z0-9._~-]+$/
|
|
5
7
|
|
|
6
|
-
/** Why `p` cannot be the actions prefix, or null. Segment-safe like the server's match.
|
|
7
|
-
|
|
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
|
|
8
12
|
if (typeof p !== 'string' || !p.startsWith('/') || p.length < 2 || p.endsWith('/'))
|
|
9
13
|
return `must start with '/', be longer than '/' and have no trailing '/' (got ${JSON.stringify(p)})`
|
|
10
14
|
const segs = p.slice(1).split('/')
|
|
@@ -19,3 +23,23 @@ export function prefixError(p: unknown): string | null {
|
|
|
19
23
|
return `${p} is reserved for the server's own /_brust/ paths`
|
|
20
24
|
return null
|
|
21
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]
|
package/src/actions-bridge.ts
CHANGED
|
@@ -1,6 +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
5
|
import type { LoaderReq } from './routes'
|
|
5
6
|
import { runInStoreContext } from './store/server-context'
|
|
6
7
|
|
|
@@ -9,6 +10,7 @@ export interface ActionsManifest {
|
|
|
9
10
|
entry: string
|
|
10
11
|
prefix: string
|
|
11
12
|
maxBodyBytes: number
|
|
13
|
+
maxResponseBytes?: number
|
|
12
14
|
}
|
|
13
15
|
/** What the worker drives: an Elysia 2 app (`app.fetch`, web-standard), or anything with that shape. */
|
|
14
16
|
export interface ActionsApp {
|
|
@@ -20,13 +22,30 @@ export interface FetchRequest {
|
|
|
20
22
|
method: string
|
|
21
23
|
url: string
|
|
22
24
|
headers: [string, string][]
|
|
23
|
-
|
|
25
|
+
/** `{heap: n}` (F82): more than 262144 bytes (`ACTION_INLINE_BODY_BYTES`, independent of the slot size), fetched with `takeFetchBody(slot)`. */
|
|
26
|
+
body: { text: string } | { b64: string } | { heap: number } | null
|
|
24
27
|
req: LoaderReq
|
|
25
28
|
}
|
|
26
|
-
/** A2 meta:
|
|
29
|
+
/** A2 meta: `status`, `headers`, and (F82) `heap` — the byte length of a response body that crossed by
|
|
30
|
+
* `putFetchReply` instead of the slot (the slot body is then empty). Never a store. */
|
|
27
31
|
export interface FetchMeta {
|
|
28
32
|
status: number
|
|
29
33
|
headers: [string, string][]
|
|
34
|
+
heap?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** F82: the worker→Rust heap channel for the in-flight `fetch` call of a slot. The default is the napi addon,
|
|
38
|
+
* loaded lazily (so a fetch that never needs it never touches the binding). */
|
|
39
|
+
export interface HeapIo {
|
|
40
|
+
takeBody(slot: number): Uint8Array | null
|
|
41
|
+
putReply(slot: number, body: Uint8Array): boolean
|
|
42
|
+
}
|
|
43
|
+
const nativeHeapIo: HeapIo = { takeBody: (slot) => takeFetchBody(slot), putReply: (slot, body) => putFetchReply(slot, body) }
|
|
44
|
+
|
|
45
|
+
let maxResponseBytes = 8_388_608
|
|
46
|
+
/** `startWorker` sets the manifest's `actions.maxResponseBytes` (R5). Default 8388608. */
|
|
47
|
+
export function setActionsLimits(l: { maxResponseBytes: number }): void {
|
|
48
|
+
maxResponseBytes = l.maxResponseBytes
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
let app: ActionsApp | null = null
|
|
@@ -49,21 +68,22 @@ const TEXT_PLAIN: [string, string][] = [['content-type', 'text/plain; charset=ut
|
|
|
49
68
|
|
|
50
69
|
/** A1 → web Request (R8 origin: `http://<host>` when the host pair is sane, else `localhost`). Bytes, never a
|
|
51
70
|
* string body, so no content-type is invented. GET/HEAD never carry a body (R4; `new Request` forbids one). */
|
|
52
|
-
export function toRequest(r: FetchRequest): Request {
|
|
71
|
+
export function toRequest(r: FetchRequest, heapBody?: Uint8Array): Request {
|
|
53
72
|
const host = r.headers.find(([k]) => k === 'host')?.[1]
|
|
54
73
|
const origin = `http://${host && HOST.test(host) ? host : 'localhost'}`
|
|
55
74
|
const bodyless = r.method === 'GET' || r.method === 'HEAD'
|
|
56
|
-
const
|
|
75
|
+
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')
|
|
57
77
|
return new Request(origin + r.url, { method: r.method, headers: r.headers, body })
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
/** A2 writer at `slot`'s sub-region `[slot*sub, slot*sub+sub)`, `sub = floor(view.byteLength / slots)`:
|
|
61
81
|
* `[u32 LE meta_len][meta JSON][body]`; returns the byte length. A frame that does not fit becomes R10's 500
|
|
62
82
|
* (always fits a real slot); 0 when not even that fits (Rust: BadResponse → 500). */
|
|
63
|
-
export function writeFramed(view: Uint8Array, slot: number, slots: number, status: number, headers: [string, string][], body: Uint8Array): number {
|
|
83
|
+
export function writeFramed(view: Uint8Array, slot: number, slots: number, status: number, headers: [string, string][], body: Uint8Array, heap?: number): number {
|
|
64
84
|
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
65
85
|
const dst = view.subarray(slot * sub, slot * sub + sub)
|
|
66
|
-
const meta = enc.encode(JSON.stringify({ status, headers } satisfies FetchMeta))
|
|
86
|
+
const meta = enc.encode(JSON.stringify((heap === undefined ? { status, headers } : { status, headers, heap }) satisfies FetchMeta))
|
|
67
87
|
const total = 4 + meta.length + body.length
|
|
68
88
|
if (total <= sub) return put(dst, meta, body)
|
|
69
89
|
console.error(`[brust] action response ${total} bytes exceeds the ${sub}-byte slot (F82) — answered 500`)
|
|
@@ -92,23 +112,60 @@ export function readFramed(view: Uint8Array, slot: number, slots: number, len: n
|
|
|
92
112
|
|
|
93
113
|
const fail = (view: Uint8Array, slot: number, slots: number, msg: string) => writeFramed(view, slot, slots, 500, TEXT_PLAIN, enc.encode(msg))
|
|
94
114
|
|
|
95
|
-
/** The `
|
|
96
|
-
|
|
115
|
+
/** The store-scoped core shared by `runFetch` and `fetchInScope`: R1/E5 — one store scope around the handler AND the
|
|
116
|
+
* body read (a lazy body may still touch stores); set-cookie values kept apart. */
|
|
117
|
+
export async function fetchParts(a: ActionsApp, req: Request): Promise<{ status: number; pairs: [string, string][]; body: Uint8Array }> {
|
|
118
|
+
return runInStoreContext(async () => {
|
|
119
|
+
const res = await a.fetch(req)
|
|
120
|
+
const pairs: [string, string][] = []
|
|
121
|
+
res.headers.forEach((v, k) => {
|
|
122
|
+
if (k !== 'set-cookie') pairs.push([k, v])
|
|
123
|
+
})
|
|
124
|
+
for (const c of res.headers.getSetCookie()) pairs.push(['set-cookie', c])
|
|
125
|
+
return { status: res.status, pairs, body: new Uint8Array(await res.arrayBuffer()) }
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Host-agnostic entry (Cloudflare `action(req)`): the same scope and framing-free parts as `runFetch`, as a Response. */
|
|
130
|
+
export async function fetchInScope(a: ActionsApp, req: Request): Promise<Response> {
|
|
131
|
+
const { status, pairs, body } = await fetchParts(a, req)
|
|
132
|
+
const headers = new Headers()
|
|
133
|
+
for (const [k, v] of pairs) headers.append(k, v)
|
|
134
|
+
return new Response([101, 204, 205, 304].includes(status) ? null : (body as BodyInit), { status, headers })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The `action` export of a worker whose app has no `[actions]`. */
|
|
138
|
+
export const noActions = async (_: Request): Promise<Response> =>
|
|
139
|
+
new Response('brust: actions are not configured in this worker', { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
|
140
|
+
|
|
141
|
+
/** The `fetch` call (makeDispatch). Never rejects, never writes JSON: every outcome is a frame. A body of
|
|
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
|
+
* by `io.putReply` with only status/headers/`heap` in the slot (F82), unless it exceeds `max_response_bytes`. */
|
|
144
|
+
export async function runFetch(requestJson: string, view: Uint8Array, slot: number, slots: number, io: HeapIo = nativeHeapIo): Promise<number> {
|
|
97
145
|
const a = app
|
|
98
146
|
if (!a) return fail(view, slot, slots, 'brust: actions are not configured in this worker')
|
|
99
147
|
try {
|
|
100
|
-
const
|
|
148
|
+
const fr = JSON.parse(requestJson) as FetchRequest
|
|
149
|
+
let heapBody: Uint8Array | undefined
|
|
150
|
+
if (fr.body && 'heap' in fr.body) {
|
|
151
|
+
const got = io.takeBody(slot)
|
|
152
|
+
if (!got || got.length !== fr.body.heap) throw new Error(`heap request body ${got ? `${got.length} != ${fr.body.heap}` : 'missing'} bytes (F82)`)
|
|
153
|
+
heapBody = got
|
|
154
|
+
}
|
|
155
|
+
const req = toRequest(fr, heapBody)
|
|
101
156
|
// R1/E5: one store scope around the handler AND the body read (a lazy body may still touch stores).
|
|
102
|
-
const out = await
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return writeFramed(view, slot, slots, out.status, out.pairs, out.body)
|
|
157
|
+
const out = await fetchParts(a, req)
|
|
158
|
+
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
159
|
+
const metaLen = (m: FetchMeta) => enc.encode(JSON.stringify(m)).length
|
|
160
|
+
if (4 + metaLen({ status: out.status, headers: out.pairs }) + out.body.length <= sub) return writeFramed(view, slot, slots, out.status, out.pairs, out.body)
|
|
161
|
+
if (out.body.length > maxResponseBytes) {
|
|
162
|
+
console.error(`[brust] action response body ${out.body.length} bytes exceeds max_response_bytes ${maxResponseBytes} — answered 500`)
|
|
163
|
+
return writeFramed(view, slot, slots, 500, TEXT_PLAIN, enc.encode(`brust: action response exceeds ${maxResponseBytes} bytes`))
|
|
164
|
+
}
|
|
165
|
+
// The meta alone must fit; otherwise writeFramed's own 500 (R10) answers.
|
|
166
|
+
if (4 + metaLen({ status: out.status, headers: out.pairs, heap: out.body.length }) > sub) return writeFramed(view, slot, slots, out.status, out.pairs, out.body)
|
|
167
|
+
if (!io.putReply(slot, out.body)) throw new Error('heap reply declined by the server (F82)')
|
|
168
|
+
return writeFramed(view, slot, slots, out.status, out.pairs, new Uint8Array(0), out.body.length)
|
|
112
169
|
} catch (e) {
|
|
113
170
|
console.error(`[brust] action failed: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
|
|
114
171
|
return fail(view, slot, slots, 'brust: action failed')
|
package/src/browser.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
/// <reference path="./css-modules.d.ts" />
|
|
1
2
|
// `@brust/core` under the `browser` export condition (and what every browser bundle of
|
|
2
3
|
// `brust build` resolves the package root to): only the side-effect-free pieces. No `run`, no
|
|
3
4
|
// config, no napi addon — a react island may import `cache` from the package root.
|
|
4
5
|
import { cacheComponent } from './cache-core'
|
|
6
|
+
export { buildTime } from './build-time'
|
|
5
7
|
import type { InvalidateArgs, InvalidateResult } from './native'
|
|
6
8
|
|
|
7
9
|
function invalidate(_args: InvalidateArgs): InvalidateResult {
|
|
@@ -26,4 +28,4 @@ export {
|
|
|
26
28
|
Outlet,
|
|
27
29
|
redirect,
|
|
28
30
|
} from './routes'
|
|
29
|
-
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
|
|
31
|
+
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, RouteSsgConfig, Verdict } from './routes'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// `brust build --actions-only` (gaps round L16, R16.3/R16.4): a dist with NO page routes: the actions app behind
|
|
2
|
+
// the same Rust admission (method gate, body caps, `Content-Encoding`, the cross-site guard, CORS), every other
|
|
3
|
+
// path a 404 problem+json. With `--target cloudflare` it also writes `<out>/cf/`: a Worker serving only actions
|
|
4
|
+
// (no Static Assets). The routes entry is neither read nor required.
|
|
5
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
7
|
+
import { buildActions } from './bundle'
|
|
8
|
+
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
9
|
+
import type { ActionsBuildConfig, BuildConfig } from './config'
|
|
10
|
+
import { BuildError } from './errors'
|
|
11
|
+
import { MANIFEST_VERSION, type ManifestJson } from './manifest'
|
|
12
|
+
|
|
13
|
+
const MARKER = '.brust'
|
|
14
|
+
|
|
15
|
+
export async function runActionsOnly(o: {
|
|
16
|
+
appRoot: string
|
|
17
|
+
dist: string
|
|
18
|
+
config: BuildConfig
|
|
19
|
+
target: 'bun' | 'cloudflare' | 'static' | undefined
|
|
20
|
+
wasmFromSource: boolean
|
|
21
|
+
statOk: (p: string) => boolean
|
|
22
|
+
assertSafeOutDir: (dist: string, appRoot: string, keep: string[]) => void
|
|
23
|
+
log: (s: string) => void
|
|
24
|
+
}): Promise<void> {
|
|
25
|
+
const { appRoot, dist, config } = o
|
|
26
|
+
const actions: ActionsBuildConfig | null = config.actions
|
|
27
|
+
if (!actions) throw new BuildError('actions-only', '--actions-only needs an [actions] table in brust.toml')
|
|
28
|
+
if (o.target === 'static') throw new BuildError('actions-only', '--actions-only builds no pages: it cannot be combined with --target static')
|
|
29
|
+
const entry = resolve(appRoot, actions.entry)
|
|
30
|
+
const rel = relative(appRoot, entry)
|
|
31
|
+
if (isAbsolute(actions.entry) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !o.statOk(entry))
|
|
32
|
+
throw new BuildError('actions-entry', `actions.entry ${actions.entry} is not a file inside the app`)
|
|
33
|
+
o.assertSafeOutDir(dist, appRoot, [entry])
|
|
34
|
+
if (config.cache) o.log('[brust] actions-only: [cache] and route options do not apply (ignored)')
|
|
35
|
+
if (actions.baseUrl !== undefined) o.log('[brust] actions-only: [actions] base_url is for the pages that call these actions (ignored here)')
|
|
36
|
+
const cf: CfPrepared | null = o.target === 'cloudflare' ? await prepareCloudflare(appRoot, config.cloudflare, { fromSource: o.wasmFromSource, env: process.env }) : null
|
|
37
|
+
|
|
38
|
+
const tmp = join(dirname(dist), `.${basename(dist)}.brust-tmp`)
|
|
39
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
40
|
+
mkdirSync(tmp, { recursive: true })
|
|
41
|
+
try {
|
|
42
|
+
const manifest: ManifestJson = {
|
|
43
|
+
version: MANIFEST_VERSION,
|
|
44
|
+
routes: [],
|
|
45
|
+
components: {},
|
|
46
|
+
assets: { runtime: '', 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
|
+
}
|