brustjs 0.1.34-alpha → 0.1.36-alpha

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 CHANGED
@@ -13,10 +13,13 @@
13
13
 
14
14
  </div>
15
15
 
16
- React on the server, Rust everywhere else. One Bun host process; the HTTP accept
17
- loop and worker pool are pure Rust, loaded as a `.node` native module (napi-rs).
18
- Renders cross into Bun Worker threads via `ThreadsafeFunction` and return over
19
- per-worker `SharedArrayBuffer`. `tokio-uring` (io_uring) on Linux, `tokio` on macOS.
16
+ React on the server, Rust everywhere else. One Bun host process; the HTTP server
17
+ (hyper 1.x, HTTP/1.1 + HTTP/2) and worker pool are pure Rust, loaded as a `.node`
18
+ native module (napi-rs). Renders cross into Bun Worker threads via
19
+ `ThreadsafeFunction` (request as inline JSON) and return over a per-worker
20
+ `SharedArrayBuffer`. One worker can hold several renders in-flight
21
+ (`renderSlots`), overlapping I/O-bound (Suspense) renders. Multi-thread tokio
22
+ runtime — runs the same everywhere (no io_uring / seccomp caveat).
20
23
 
21
24
  > Published on npm as [`brustjs`](https://www.npmjs.com/package/brustjs) (the
22
25
  > `brust` name is taken). Alpha — see **Status**.
@@ -133,7 +136,8 @@ bun test tests/integration.test.ts # integration (real server)
133
136
  ```
134
137
 
135
138
  ```
136
- crates/brust/ Rust: accept loop, worker pool, napi exports, SAB
139
+ crates/brust-core/ Rust core (pure, zero napi): hyper server, worker pool, routing, cache
140
+ crates/brust/ Thin napi cdylib over brust-core (the .node)
137
141
  crates/jsx-rust-compiler/ JSX → jinja compiler for native: true routes
138
142
  runtime/ Bun-side: routing, render, actions, store, native directives, CLI
139
143
  example/ pokedex native-first demo
@@ -142,14 +146,15 @@ bench/ · docs/ · architecture.md
142
146
 
143
147
  ## Status
144
148
 
145
- Alpha, solo-developed. Linux is tier-1 (io_uring; glibc + musl, 6 prebuilt platform
146
- binaries). Known partials: `brustjs dev` reload is a full worker-respawn (not
147
- state-preserving HMR) TS, islands, and `.module.css` all reload that way.
148
- Tailwind is opt-in the scaffold adds it as a project dependency; `@import "tailwindcss"`
149
- resolves from your own `node_modules`. Deployment note: the io_uring server needs `io_uring_*`
150
- syscalls permitted a default-seccomp container (Docker/k8s) must allow them or run
151
- `--security-opt seccomp=unconfined`. Roadmap and limitations in
152
- [`architecture.md`](./architecture.md).
149
+ Alpha, solo-developed. Linux is tier-1 (glibc + musl, 6 prebuilt platform
150
+ binaries); the multi-thread tokio server runs under default container seccomp
151
+ no `io_uring` exception needed. The server speaks **HTTP/1.1 + HTTP/2** with
152
+ optional in-process **TLS**, and a worker can hold several renders in-flight
153
+ (**`renderSlots`**) to overlap Suspense / loader-bound requests. Known partials:
154
+ `brustjs dev` reload is a full worker-respawn (not state-preserving HMR) TS,
155
+ islands, and `.module.css` all reload that way. Tailwind is opt-in — the scaffold
156
+ adds it as a project dependency; `@import "tailwindcss"` resolves from your own
157
+ `node_modules`. Roadmap and limitations in [`architecture.md`](./architecture.md).
153
158
 
154
159
  MIT.
155
160
 
@@ -78,7 +78,7 @@ export const behavior = ({ props }: { props: AddTeamProps }) => {
78
78
  // emitted by the compiler as x-props="{{ (data) | json_attr }}" (XSS-safe).
79
79
  export default function AddToTeamButton({ data }: { data: AddTeamProps }) {
80
80
  return (
81
- <div x-data="addToTeamButton" x-props={data} className="relative">
81
+ <div x-props={data} className="relative">
82
82
  <Plus
83
83
  size={16}
84
84
  className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white"
@@ -42,7 +42,7 @@ export const behavior = () => {
42
42
  // bind and on every SPA navigation.
43
43
  export default function Breadcrumb({ crumb }: { crumb: string }) {
44
44
  return (
45
- <b x-data="breadcrumb" x-text="label" className="text-slate-600 dark:text-slate-300">
45
+ <b x-text="label" className="text-slate-600 dark:text-slate-300">
46
46
  {crumb}
47
47
  </b>
48
48
  )
@@ -52,7 +52,7 @@ export const behavior = ({ props }: { el: HTMLElement; props: unknown }) => {
52
52
  // behavior's props — one prop, no separate pre-stringified `dexProps`.
53
53
  export default function DexFilter({ items }: { items: Card[] }) {
54
54
  return (
55
- <section x-data="dexFilter" x-props={items}>
55
+ <section x-props={items}>
56
56
  <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
57
57
  <div className="relative w-full sm:max-w-xs">
58
58
  <Search
@@ -29,7 +29,6 @@ export const behavior = () => {
29
29
  export default function HeroSearch() {
30
30
  return (
31
31
  <form
32
- x-data="heroSearch"
33
32
  x-on-submit="go"
34
33
  className="mx-auto mt-8 flex w-full max-w-md items-center gap-2 rounded-2xl bg-white/95 p-2 shadow-lg ring-1 ring-black/5 dark:bg-slate-900/90 dark:ring-white/10"
35
34
  >
@@ -31,7 +31,6 @@ export const behavior = ({ el }: { el: HTMLElement }) => {
31
31
  export default function NavLink({ href, label }: { href: string; label: string }) {
32
32
  return (
33
33
  <a
34
- x-data="navLink"
35
34
  x-bind-class="cls"
36
35
  x-bind-aria-current="current"
37
36
  className="inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
@@ -45,7 +45,6 @@ export default function ThemeToggle() {
45
45
  return (
46
46
  <button
47
47
  type="button"
48
- x-data="themeToggle"
49
48
  x-on-click="toggle"
50
49
  aria-label="Toggle theme"
51
50
  className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@@ -8,6 +8,40 @@ import { cachedFetch } from 'brustjs'
8
8
 
9
9
  export const API = 'https://pokeapi.co/api/v2'
10
10
 
11
+ // Process-lifetime memo of PokeAPI JSON. `cachedFetch` dedupes only WITHIN one
12
+ // request (it's AsyncLocalStorage-scoped), so on its own EVERY request re-hits
13
+ // pokeapi.co over the network (~700 ms) — at 120 conns `/pokedex` collapses to
14
+ // ~170 rps, bound entirely by that round-trip. PokeAPI is IMMUTABLE reference
15
+ // data, so the parsed JSON is cached for the process lifetime here: the first
16
+ // request warms it, every later request renders from memory. Only successful
17
+ // (2xx) responses are cached; a non-ok response or a network error resolves
18
+ // `null` and is NOT cached, so a transient 429/500 or a typo'd name retries.
19
+ // (Per-request personalization — theme cookie, team — lives in `chrome()`, not
20
+ // here, so it is never cached.)
21
+ const jsonCache = new Map<string, Promise<unknown>>()
22
+
23
+ async function getJson<T>(url: string): Promise<T | null> {
24
+ const hit = jsonCache.get(url) as Promise<T | null> | undefined
25
+ if (hit) return hit
26
+ const p = (async (): Promise<T | null> => {
27
+ const res = await cachedFetch(url)
28
+ if (!res.ok) return null
29
+ return (await res.json()) as T
30
+ })()
31
+ jsonCache.set(url, p)
32
+ // Evict anything that didn't yield cacheable data (null / rejected) so the
33
+ // next request can retry rather than memoizing a transient failure forever.
34
+ p.then(
35
+ (v) => {
36
+ if (v === null && jsonCache.get(url) === p) jsonCache.delete(url)
37
+ },
38
+ () => {
39
+ if (jsonCache.get(url) === p) jsonCache.delete(url)
40
+ },
41
+ )
42
+ return p
43
+ }
44
+
11
45
  export const idFromUrl = (url: string): number => Number((url.match(/\/pokemon\/(\d+)\//) || [])[1])
12
46
 
13
47
  /** Official-artwork PNG from the PokeAPI sprites CDN — derived from id so the
@@ -81,12 +115,11 @@ export interface RawEvolutionStage {
81
115
  }
82
116
 
83
117
  export async function fetchList(offset: number, limit: number) {
84
- const res = await cachedFetch(`${API}/pokemon?limit=${limit}&offset=${offset}`)
85
- if (!res.ok) throw new Error(`PokeAPI list ${res.status}`)
86
- const page = (await res.json()) as {
118
+ const page = await getJson<{
87
119
  count: number
88
120
  results: { name: string; url: string }[]
89
- }
121
+ }>(`${API}/pokemon?limit=${limit}&offset=${offset}`)
122
+ if (!page) throw new Error('PokeAPI list fetch failed')
90
123
  return {
91
124
  results: page.results.map((r) => ({ id: idFromUrl(r.url), name: r.name })),
92
125
  total: page.count,
@@ -94,9 +127,8 @@ export async function fetchList(offset: number, limit: number) {
94
127
  }
95
128
 
96
129
  export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
97
- const res = await cachedFetch(`${API}/pokemon/${name}`)
98
- if (!res.ok) return null
99
- const p = (await res.json()) as any
130
+ const p = await getJson<any>(`${API}/pokemon/${name}`)
131
+ if (!p) return null
100
132
  return {
101
133
  id: p.id,
102
134
  name: p.name,
@@ -110,8 +142,8 @@ export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
110
142
  }
111
143
 
112
144
  export async function fetchSpecies(id: number): Promise<RawSpecies> {
113
- const res = await cachedFetch(`${API}/pokemon-species/${id}`)
114
- const s = (await res.json()) as any
145
+ const s = await getJson<any>(`${API}/pokemon-species/${id}`)
146
+ if (!s) return { flavorText: '', genus: '', evolutionUrl: '' }
115
147
  const flavor = s.flavor_text_entries?.find((e: any) => e.language.name === 'en')?.flavor_text as
116
148
  | string
117
149
  | undefined
@@ -126,9 +158,8 @@ export async function fetchSpecies(id: number): Promise<RawSpecies> {
126
158
  * flattened to the first branch — noted as an open question in the design. */
127
159
  export async function fetchEvolution(url: string): Promise<RawEvolutionStage[]> {
128
160
  if (!url) return []
129
- const res = await cachedFetch(url)
130
- if (!res.ok) return []
131
- const data = (await res.json()) as any
161
+ const data = await getJson<any>(url)
162
+ if (!data) return []
132
163
  const stages: RawEvolutionStage[] = []
133
164
  let node = data.chain
134
165
  while (node) {
@@ -164,9 +195,8 @@ export const ALL_TYPES = [
164
195
 
165
196
  /** Fetch one type's damage relations → a map of defendingType → multiplier. */
166
197
  export async function fetchTypeRelations(type: string): Promise<Record<string, number>> {
167
- const res = await cachedFetch(`${API}/type/${type}`)
168
- if (!res.ok) return {}
169
- const d = (await res.json()) as any
198
+ const d = await getJson<any>(`${API}/type/${type}`)
199
+ if (!d) return {}
170
200
  const rel = d.damage_relations
171
201
  const out: Record<string, number> = {}
172
202
  for (const t of rel.double_damage_to || []) out[t.name] = 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.34-alpha",
3
+ "version": "0.1.36-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,12 +40,12 @@
40
40
  "typescript": "^6.0.3"
41
41
  },
42
42
  "optionalDependencies": {
43
- "brustjs-darwin-x64": "0.1.34-alpha",
44
- "brustjs-darwin-arm64": "0.1.34-alpha",
45
- "brustjs-linux-x64-gnu": "0.1.34-alpha",
46
- "brustjs-linux-arm64-gnu": "0.1.34-alpha",
47
- "brustjs-linux-x64-musl": "0.1.34-alpha",
48
- "brustjs-linux-arm64-musl": "0.1.34-alpha"
43
+ "brustjs-darwin-x64": "0.1.36-alpha",
44
+ "brustjs-darwin-arm64": "0.1.36-alpha",
45
+ "brustjs-linux-x64-gnu": "0.1.36-alpha",
46
+ "brustjs-linux-arm64-gnu": "0.1.36-alpha",
47
+ "brustjs-linux-x64-musl": "0.1.36-alpha",
48
+ "brustjs-linux-arm64-musl": "0.1.36-alpha"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^19.2.6",
@@ -483,6 +483,7 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
483
483
  path: string,
484
484
  componentSources?: Record<string, string>,
485
485
  lucideIcons?: Record<string, string>,
486
+ directiveNames?: Record<string, string>,
486
487
  ) => { template: string; islandsJson: string; warnings?: string[] })
487
488
  | null = null
488
489
  if (nativeRoutes.length > 0) {
@@ -573,9 +574,24 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
573
574
  const lucideIcons: Record<string, string> = {}
574
575
  for (const p of lucidePaths) Object.assign(lucideIcons, await extractLucideIcons(p))
575
576
 
577
+ // Build directive name map: for each ident in `sources` whose source text
578
+ // contains `export const behavior`, resolve its absolute path from
579
+ // `mergedImports` and derive the canonical directive name. Uses a dynamic
580
+ // import to avoid a circular dependency (native/build.ts → scanImports here).
581
+ const { directiveName } = await import('../native/build.ts')
582
+ const BEHAVIOR_RE = /export\s+const\s+behavior\b/
583
+ const directiveNames: Record<string, string> = {}
584
+ for (const [ident, src] of Object.entries(sources)) {
585
+ if (!BEHAVIOR_RE.test(src)) continue
586
+ const ref = mergedImports.get(ident)
587
+ if (ref && !ref.bare && typeof ref.spec === 'string') {
588
+ directiveNames[ident] = directiveName(ref.spec, process.cwd())
589
+ }
590
+ }
591
+
576
592
  let compiled: { template: string; islandsJson: string; warnings?: string[] }
577
593
  try {
578
- compiled = compileJsx!(routeSource, routeSourcePath, sources, lucideIcons)
594
+ compiled = compileJsx!(routeSource, routeSourcePath, sources, lucideIcons, directiveNames)
579
595
  } catch (e) {
580
596
  throw new Error(
581
597
  `native route "${name}" failed to compile (${routeSourcePath}):\n${String(e)}`,
@@ -1,5 +1,15 @@
1
1
  /* auto-generated by NAPI-RS */
2
2
  /* eslint-disable */
3
+ /**
4
+ * Graceful drain: stop accepting new connections, tell every in-flight one to
5
+ * finish its current request then close, and resolve once they've all drained
6
+ * (or `timeout_ms` elapses). The TS SIGINT handler awaits this before
7
+ * `process.exit` so an in-flight render/stream isn't cut off mid-response (the
8
+ * teardown that produced spurious `split_meta` errors under load). Idempotent-ish:
9
+ * the accept loop only drains once; a second call just re-resolves.
10
+ */
11
+ export declare function beginDrain(timeoutMs: number): Promise<NapiResult<undefined>>
12
+
3
13
  export declare function beginServe(opts: ServeOptions): NapiResult<undefined>
4
14
 
5
15
  export interface CachedIslandJs {
@@ -7,7 +17,7 @@ export interface CachedIslandJs {
7
17
  props: string
8
18
  }
9
19
 
10
- export declare function compileJsx(source: string, path: string, componentSources?: Record<string, string> | undefined | null, lucideIcons?: Record<string, string> | undefined | null): NapiCompiledJsx
20
+ export declare function compileJsx(source: string, path: string, componentSources?: Record<string, string> | undefined | null, lucideIcons?: Record<string, string> | undefined | null, directiveNames?: Record<string, string> | undefined | null): NapiCompiledJsx
11
21
 
12
22
  export declare function configureCache(maxEntries: number): NapiResult<undefined>
13
23
 
@@ -120,7 +130,7 @@ export declare function napiRegisterWsPaths(paths: Array<string>): NapiResult<un
120
130
  * - Ack receiver dropped (handle_conn torn down mid-stream) → NAPI Err
121
131
  * (NOT hang — worker's sink propagates via cb(err) to renderer Promise).
122
132
  */
123
- export declare function napiRenderChunk(workerId: number, len: number): Promise<NapiResult<undefined>>
133
+ export declare function napiRenderChunk(workerId: number, slot: number, len: number): Promise<NapiResult<undefined>>
124
134
 
125
135
  /**
126
136
  * Buffering-path finalizer: equivalent to `napi_render_chunk(_, len)` followed
@@ -134,7 +144,7 @@ export declare function napiRenderChunk(workerId: number, len: number): Promise<
134
144
  *
135
145
  * Same error semantics as `napi_render_chunk`.
136
146
  */
137
- export declare function napiRenderChunkFinal(workerId: number, len: number): Promise<NapiResult<undefined>>
147
+ export declare function napiRenderChunkFinal(workerId: number, slot: number, len: number): Promise<NapiResult<undefined>>
138
148
 
139
149
  /**
140
150
  * Sub-project J — render via minijinja using SAB-side-channeled loader data.
@@ -165,7 +175,7 @@ export declare function napiRenderChunkFinal(workerId: number, len: number): Pro
165
175
  * - `jinja::render` failure → writes a framed 500 into the SAB and returns its
166
176
  * length (the protocol error is converted to an HTTP 500 on the wire).
167
177
  */
168
- export declare function napiRenderJinja(workerId: number, dataLen: number, templateName: string, status?: number | undefined | null): NapiResult<number>
178
+ export declare function napiRenderJinja(workerId: number, slot: number, dataLen: number, templateName: string, status?: number | undefined | null): NapiResult<number>
169
179
 
170
180
  /**
171
181
  * Drop the connection's sender, which signals the per-conn task to exit
@@ -231,7 +241,7 @@ export declare function napiWsSignalOpen(connId: bigint, status: number, body: B
231
241
  */
232
242
  export declare function registerActions(endpoints: Array<EndpointReg>): NapiResult<number>
233
243
 
234
- export declare function registerRenderer(buf: Uint8Array, f: (arg: number | string) => Promise<number>): NapiResult<number>
244
+ export declare function registerRenderer(buf: Uint8Array, slots: number, f: (arg0: string, arg1: number) => Promise<number>): NapiResult<number>
235
245
 
236
246
  export declare function registerRoutes(configs: Array<string>): NapiResult<number>
237
247
 
@@ -259,6 +269,21 @@ export interface ServeOptions {
259
269
  tuning?: ServeTuning
260
270
  /** Optional action prefix override. Defaults to `/_brust/action`. */
261
271
  actionPrefix?: string
272
+ /**
273
+ * Optional in-process TLS: PEM certificate (chain) path. When BOTH this and
274
+ * `tls_key_path` are present, the server terminates TLS itself (ALPN
275
+ * h2+http/1.1). Omit either to serve plaintext (unchanged default).
276
+ */
277
+ tlsCertPath?: string
278
+ /** Optional in-process TLS: PEM private-key path. See `tls_cert_path`. */
279
+ tlsKeyPath?: string
280
+ /**
281
+ * Optional minimum TLS version: `"1.2"` (default, = rustls TLS 1.2 + 1.3
282
+ * safe defaults) or `"1.3"` (TLS 1.3 only, for hardened/compliance
283
+ * deployments). Case-insensitive, trimmed. Only meaningful when TLS is
284
+ * configured (cert + key present). An unrecognized value is rejected.
285
+ */
286
+ tlsMinVersion?: string
262
287
  }
263
288
 
264
289
  /**
@@ -287,6 +312,22 @@ export interface ServeTuning {
287
312
  * Default 10000.
288
313
  */
289
314
  claimTimeoutMs?: number
315
+ /**
316
+ * tokio I/O runtime worker-thread count for the hyper server. Runs inside
317
+ * Bun (which has its own threads + render workers), so this is NOT
318
+ * one-per-core. Default `min(available_parallelism, 4)` (fallback 2).
319
+ */
320
+ workerThreads?: number
321
+ /**
322
+ * Number of render slots per Bun worker (concurrent in-flight renders per
323
+ * isolate). Default 1 (single in-flight render per worker — byte-identical
324
+ * to the pre-multi-slot behaviour). The COUNT reaches `register_renderer`
325
+ * via the `BRUST_RENDER_SLOTS` worker env var set in `runtime/index.ts`; it
326
+ * is NOT consumed by the Rust `Tuning` struct here (the pool learns it from
327
+ * `dispatch.slot_count()`). Carried on `ServeTuning` only so the JS layer
328
+ * has one tunable surface.
329
+ */
330
+ renderSlots?: number
290
331
  }
291
332
 
292
333
  export declare function untilReady(timeoutMs: number): Promise<NapiResult<undefined>>