@brust/core 0.0.0-stage → 2.0.0-alpha.2
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 +91 -2
- package/bin/brust +4 -0
- package/native/index.d.ts +162 -0
- package/native/index.js +791 -0
- package/package.json +89 -4
- package/src/actions/client.ts +54 -0
- package/src/actions/elysia.ts +15 -0
- package/src/actions/load.ts +22 -0
- package/src/actions/prefix.ts +23 -0
- package/src/actions-bridge.ts +155 -0
- package/src/browser.ts +29 -0
- package/src/build/bundle.ts +395 -0
- package/src/build/compile.ts +147 -0
- package/src/build/config.ts +87 -0
- package/src/build/errors.ts +11 -0
- package/src/build/index.ts +199 -0
- package/src/build/manifest.ts +322 -0
- package/src/build/scan.ts +42 -0
- package/src/cache-core.ts +15 -0
- package/src/cache.ts +29 -0
- package/src/cli.ts +96 -0
- package/src/config.ts +200 -0
- package/src/index.ts +18 -0
- package/src/native.ts +95 -0
- package/src/routes.ts +257 -0
- package/src/run.ts +157 -0
- package/src/server.ts +5 -0
- package/src/store/core-adapter.ts +32 -0
- package/src/store/index.ts +4 -0
- package/src/store/react.ts +1 -0
- package/src/store/server-context.ts +55 -0
- package/src/worker.ts +347 -0
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @brust/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Rust serves the pages; N Bun workers answer only `loader` and `jobs` calls. One napi addon
|
|
4
|
+
(`crates/brust-napi`, loader generated into `native/` by `napi build`) binds `brust-server` and
|
|
5
|
+
`brust-compiler`. Workspace-private (0.0.0) until m2e publishes it; peers `react`/`react-dom` 19.
|
|
6
|
+
|
|
7
|
+
## Routes (`routes.tsx`)
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { defineRoutes, notFound } from '@brust/core/routes'
|
|
11
|
+
export const routes = defineRoutes([
|
|
12
|
+
{ Component: AppLayout, children: [ // layout renders <Outlet />
|
|
13
|
+
{ path: '/', Component: HomePage }, // static: never calls Bun
|
|
14
|
+
{ path: '/items/{id}', Component: ItemPage,
|
|
15
|
+
loader: async ({ params }) => (params.id === 'x' ? notFound({}) : { item: await load(params.id) }),
|
|
16
|
+
cache: { ttl_seconds: 60, tags: ['items'] } }, // a HIT never calls Bun
|
|
17
|
+
] },
|
|
18
|
+
])
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Route fields are exactly `path`, `Component`, `loader`, `cache`, `children`; anything else fails the
|
|
22
|
+
build with `<field> is not supported in M2 (M3)`. Loaders run root → leaf, results merge flat
|
|
23
|
+
(later keys win); return `notFound(data)` / `redirect(location, status)` or throw
|
|
24
|
+
`httpError(status, body)` for a verdict. `cache(Comp, opts)` and `cache.invalidate(…)` ship too.
|
|
25
|
+
|
|
26
|
+
## `brust build [entry=routes.tsx] [--out-dir dist]`
|
|
27
|
+
|
|
28
|
+
Compiles every route component through lowering, then writes (always from scratch):
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
dist/manifest.json the only build → server contract (spec S6)
|
|
32
|
+
dist/jinja/<id>.jinja templates dist/jobs.js + jobs/*.server.ts worker jobs
|
|
33
|
+
dist/client/*-<hex>.js runtime, native chunks, react-<id> island chunks (served immutable)
|
|
34
|
+
dist/public/ copied from ./public dist/index.js `bun dist/index.js` = start
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Every route component's `children[]` also gets one static entry per native descendant (at any depth) that has a client chunk, so its `<script>` is linked once per page.
|
|
38
|
+
|
|
39
|
+
A diagnostic (`nested-instance`, `outlet-in-react`, `outlet-outside-layout`, unsupported route
|
|
40
|
+
field, unresolvable Component) prints `error <rule> <message>` and exits 1. The route tree is
|
|
41
|
+
validated whether or not the entry used `defineRoutes` (`route-config`, `duplicate-route` for two
|
|
42
|
+
patterns the router cannot hold together, e.g. `/a` and `/a/`).
|
|
43
|
+
|
|
44
|
+
Server-only code never reaches a browser bundle (`error server-only-in-client <importer> imports
|
|
45
|
+
<spec>`, at any import depth, react islands included): `node:*` / `bun:*` / bare Node builtins
|
|
46
|
+
(unless an npm package of that name is installed), `@brust/core/server|native`, any
|
|
47
|
+
`*.server.*` file, and the paths/prefixes listed in `brust.toml`:
|
|
48
|
+
|
|
49
|
+
```toml
|
|
50
|
+
[build]
|
|
51
|
+
server_only = ["lib/server", "@acme/db"] # import prefixes, or paths relative to the app root
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Loaders and precompute jobs may use all of it (they run in Bun). In the browser `@brust/core`
|
|
55
|
+
resolves to a side-effect-free entry (`cache`, route helpers, verdicts; `cache.invalidate` throws).
|
|
56
|
+
|
|
57
|
+
The out dir is rebuilt in a sibling temp dir and swapped in only on success (a failed build leaves
|
|
58
|
+
the previous one untouched); an out dir that is the filesystem root, the home dir, the app root or
|
|
59
|
+
an ancestor of it, or that holds the entry / a route Component / `public/`, is refused
|
|
60
|
+
(`out-dir-unsafe`). Relative imports of
|
|
61
|
+
generated files resolve against the component's source dir, bare imports against the app root, so
|
|
62
|
+
`--out-dir` may point anywhere; at run time `dist/` must still resolve `react` and `@brust/core`
|
|
63
|
+
(keep it inside the app, or next to a `node_modules` that has them).
|
|
64
|
+
|
|
65
|
+
## `brust start [--port N] [--workers N] [--dist-dir dist] [--entry routes.tsx]`
|
|
66
|
+
|
|
67
|
+
Prints `[brust] listening on <addr>` then `[brust] ready (N workers)`. `--port 0` picks a free port.
|
|
68
|
+
SIGINT/SIGTERM drains gracefully (`BRUST_DRAIN_TIMEOUT_MS`); a second signal exits at once.
|
|
69
|
+
Config precedence: env (`BRUST_ADDR`, `BRUST_PORT`, `BRUST_WORKERS`, `BRUST_RENDER_SLOTS`,
|
|
70
|
+
`BRUST_DRAIN_TIMEOUT_MS`, `BRUST_BOOT_TIMEOUT_MS`, `BRUST_CALL_TIMEOUT_MS`, `BRUST_DIST_DIR`, `BRUST_APP_ENTRY`) > flags
|
|
71
|
+
(`--port`, `--workers`, `--dist-dir`, `--entry`) > `brust.toml` (`[server] address/port`,
|
|
72
|
+
`[workers] count`) > defaults (`localhost:1337`, one worker per CPU, `min(cores, 16)` slots per worker (each slot is a 256 KiB shared response buffer, so 10 slots = 2.5 MiB per worker), 10 s drain, 30 s
|
|
73
|
+
worker boot timeout, 30 s call timeout).
|
|
74
|
+
A loader or job call that has not settled within `BRUST_CALL_TIMEOUT_MS` answers **504**; its worker
|
|
75
|
+
slot stays claimed until the call settles (the late result is discarded), so a stuck call costs one
|
|
76
|
+
slot, never a frozen worker. Waiting for a free slot is the separate 503 "all workers busy".
|
|
77
|
+
`GET /_brust/cache/stats` reports L1/job cache counters plus `loader_calls` / `job_calls` /
|
|
78
|
+
`timed_out_calls`.
|
|
79
|
+
|
|
80
|
+
## Worker wire (contract 7, `crates/brust-server/src/protocol.rs`)
|
|
81
|
+
|
|
82
|
+
Requests arrive as inline JSON; responses go into the worker's SharedArrayBuffer slot (never
|
|
83
|
+
past `floor(len / slots)` bytes: an oversize response becomes `{"error":…}`). A handler never rejects.
|
|
84
|
+
|
|
85
|
+
`loader` `{routeId, params, path, req}` → `{ok:true, data}` | `{verdict, …}` | `{error}`;
|
|
86
|
+
`jobs` `{jobs:[{id, componentId, kind, inputs, target?, row?}]}` → `{results:[{id, value}|{id, error}]}`
|
|
87
|
+
(an `ssr` job renders `jobs[target]` with the manifest job's `literals` merged over `inputs`).
|
|
88
|
+
|
|
89
|
+
**M2 limits:** the route fields above only; per-row child instances one level deep (a nested
|
|
90
|
+
instance is a build error); a react child's props must be props paths or literals; no worker respawn.
|
|
91
|
+
**Gates:** `bun run build:debug && bun run typecheck && bun test` (CI runs each server-starting
|
|
92
|
+
file alone; e2e: `bun test --timeout 120000 test/e2e.test.ts`).
|
package/bin/brust
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/* auto-generated by NAPI-RS */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which binding artifact the generated loader actually loaded: `'native'` for
|
|
6
|
+
* a native addon, otherwise the `platformArchABI` of the WASI flavor. Every
|
|
7
|
+
* flavor napi-rs can build is listed, because `NAPI_RS_NATIVE_LIBRARY_PATH`
|
|
8
|
+
* can point the loader at a WASI artifact this package does not build itself.
|
|
9
|
+
*/
|
|
10
|
+
export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Graceful drain of the current server: stop accepting, wait for in-flight
|
|
14
|
+
* connections up to `timeout_ms`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function beginDrain(timeoutMs: number): Promise<void>
|
|
17
|
+
|
|
18
|
+
/** `cache.invalidate({ key?, tags?, path?, method? })` (spec §5). */
|
|
19
|
+
export declare function cacheInvalidate(args: NapiInvalidateArgs): NapiInvalidateResult
|
|
20
|
+
|
|
21
|
+
/** `/_brust/cache/stats` JSON (snake_case keys, as the server serialises it). */
|
|
22
|
+
export declare function cacheStats(): string
|
|
23
|
+
|
|
24
|
+
export interface CompiledComponent {
|
|
25
|
+
id: string
|
|
26
|
+
source: string
|
|
27
|
+
/** `ComponentIR` as JSON (snake_case keys, as the compiler serialises it). */
|
|
28
|
+
ir: string
|
|
29
|
+
jinja: string
|
|
30
|
+
serverTs?: string
|
|
31
|
+
clientJs?: string
|
|
32
|
+
diagnostics: Array<NapiDiagnostic>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CompiledTree {
|
|
36
|
+
components: Array<CompiledComponent>
|
|
37
|
+
error?: NapiDiagnostic
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Compiles `path` (relative to `root`) and every child it reaches through lowering. */
|
|
41
|
+
export declare function compileTree(path: string, root: string, runtimeImport: string, serverOnly: Array<string>): CompiledTree
|
|
42
|
+
|
|
43
|
+
/** The bound address as `host:port` (port 0 resolves here). */
|
|
44
|
+
export declare function localAddr(): string
|
|
45
|
+
|
|
46
|
+
/** M3-F A3 consumer `{ prefix?, maxBodyBytes?, maxResponseBytes? }` (defaults `/_brust/action`, 1 MiB, 8 MiB). Present = actions on. */
|
|
47
|
+
export interface NapiActionsOptions {
|
|
48
|
+
prefix?: string
|
|
49
|
+
maxBodyBytes?: number
|
|
50
|
+
/** F82: cap on an action response body (default 8388608). */
|
|
51
|
+
maxResponseBytes?: number
|
|
52
|
+
/** `"reject"` (default) or `"allow"` (F87): cross-site mutating action requests. */
|
|
53
|
+
crossSite?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Global CORS (0.1.x `NapiCorsOptions`, brust/src/lib.rs:79-98); napi camelCases the keys:
|
|
58
|
+
* `origins, methods, headers, exposeHeaders, credentials, maxAgeSeconds`. Validated by `start` (`cors: …`).
|
|
59
|
+
*/
|
|
60
|
+
export interface NapiCorsOptions {
|
|
61
|
+
origins: Array<string>
|
|
62
|
+
methods?: Array<string>
|
|
63
|
+
headers?: Array<string>
|
|
64
|
+
exposeHeaders?: Array<string>
|
|
65
|
+
credentials?: boolean
|
|
66
|
+
maxAgeSeconds?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface NapiDiagnostic {
|
|
70
|
+
class: string
|
|
71
|
+
rule: string
|
|
72
|
+
message: string
|
|
73
|
+
line: number
|
|
74
|
+
col: number
|
|
75
|
+
remediation: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface NapiInvalidateArgs {
|
|
79
|
+
key?: string
|
|
80
|
+
tags?: Array<string>
|
|
81
|
+
path?: string
|
|
82
|
+
method?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface NapiInvalidateResult {
|
|
86
|
+
l1Removed: number
|
|
87
|
+
jobRemoved: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* M3-P P6: plan the calling worker's in-flight loader call for `slot` from the `len` bytes it
|
|
92
|
+
* just wrote there. Returns the misses' `JobsRequest` JSON, `""` when every job hit, or `"!"`
|
|
93
|
+
* (declined: answer with the loader response already in the slot). Sync, on the worker's JS
|
|
94
|
+
* thread; never throws (a napi Error return is unreliable under Bun).
|
|
95
|
+
*/
|
|
96
|
+
export declare function planJobs(slot: number, len: number): string
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* F82: hand Rust the response body of the calling worker's in-flight `fetch` call for `slot`
|
|
100
|
+
* (too large for the SAB slot). `false` = declined (over `max_response_bytes`, not in a `fetch`
|
|
101
|
+
* call, already put, nothing bound). Sync, on the worker's JS thread; never throws.
|
|
102
|
+
*/
|
|
103
|
+
export declare function putFetchReply(slot: number, body: Uint8Array): boolean
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Register the calling worker with the current server. `buf` is a view of
|
|
107
|
+
* the worker's SharedArrayBuffer, which must outlive the process's use of
|
|
108
|
+
* this worker (`brust start` allocates it on the main thread and keeps it for
|
|
109
|
+
* the process lifetime: `dispatch.rs` BufPtr); it is split into
|
|
110
|
+
* `slots` disjoint sub-regions of `floor(buf.byteLength / slots)` bytes.
|
|
111
|
+
* `f(kind, requestJson, slot)` writes the response JSON at offset
|
|
112
|
+
* `slot * sub` and resolves with its byte length (> 0, ≤ sub). Returns the
|
|
113
|
+
* worker id.
|
|
114
|
+
*/
|
|
115
|
+
export declare function registerWorker(buf: Uint8Array, slots: number, f: (arg0: string, arg1: string, arg2: number) => Promise<number>): number
|
|
116
|
+
|
|
117
|
+
export interface StartOptions {
|
|
118
|
+
host: string
|
|
119
|
+
port: number
|
|
120
|
+
distDir: string
|
|
121
|
+
/** Workers to wait for before accepting; `0` = serve at once. */
|
|
122
|
+
workers: number
|
|
123
|
+
claimTimeoutMs?: number
|
|
124
|
+
/**
|
|
125
|
+
* A claimed worker call that has not settled after this long answers
|
|
126
|
+
* 504 (the slot stays claimed until JS settles). Absent = 30 000.
|
|
127
|
+
*/
|
|
128
|
+
callTimeoutMs?: number
|
|
129
|
+
/**
|
|
130
|
+
* tokio threads of the HTTP runtime (every page renders there); absent =
|
|
131
|
+
* one per core (`Tuning::default`).
|
|
132
|
+
*/
|
|
133
|
+
workerThreads?: number
|
|
134
|
+
l1Capacity?: number
|
|
135
|
+
jobCacheCapacity?: number
|
|
136
|
+
/** `X-Powered-By` value; absent = header not stamped. */
|
|
137
|
+
generator?: string
|
|
138
|
+
/** Global CORS policy; absent = CORS off. */
|
|
139
|
+
cors?: NapiCorsOptions
|
|
140
|
+
/** The actions subtree (M3-F); absent = no actions (byte-identical server). */
|
|
141
|
+
actions?: NapiActionsOptions
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Boot the server (manifest, routes, templates, bind). Throws on any boot
|
|
146
|
+
* error (`manifest: …`, `routes: …`, `render: …`, `cors: …`, `actions: …`,
|
|
147
|
+
* `bind failed on …`).
|
|
148
|
+
*/
|
|
149
|
+
export declare function startServer(opts: StartOptions): void
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* F82: the heap request body of the calling worker's in-flight `fetch` call for `slot`, once.
|
|
153
|
+
* `None` when there is none, nothing is bound, or the binding is ambiguous. Sync, on the
|
|
154
|
+
* worker's JS thread; never throws.
|
|
155
|
+
*/
|
|
156
|
+
export declare function takeFetchBody(slot: number): Uint8Array | null
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolves once `workers` workers registered with the current server;
|
|
160
|
+
* rejects after `timeout_ms` (the caller decides the exit policy).
|
|
161
|
+
*/
|
|
162
|
+
export declare function untilReady(timeoutMs: number): Promise<void>
|