brustjs 0.1.35-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/runtime/index.ts CHANGED
@@ -49,6 +49,15 @@ export interface ServeOptions {
49
49
  /** Max ms a render waits for a free worker before 503 (AllBusy queues
50
50
  * instead of failing fast). Default 10000. */
51
51
  claimTimeoutMs?: number
52
+ /** tokio I/O runtime worker-thread count for the hyper server. Runs inside
53
+ * Bun (which has its own threads + render workers), so this is NOT
54
+ * one-per-core. Default `min(availableParallelism, 4)` (fallback 2). */
55
+ workerThreads?: number
56
+ /** Render slots per Bun worker — concurrent in-flight renders per isolate.
57
+ * Default 1 (byte-identical to single in-flight render per worker). The
58
+ * count is propagated to each worker via the `BRUST_RENDER_SLOTS` env var
59
+ * and scales the per-worker SAB so each slot keeps the single-slot capacity. */
60
+ renderSlots?: number
52
61
  }
53
62
  }
54
63
 
@@ -56,10 +65,11 @@ export interface ServeOptions {
56
65
  // (the worker wrote `[meta_len][meta][body]` into the SAB; Rust reads it
57
66
  // directly), `0` → the worker used the chunk channel via
58
67
  // `napi.renderChunk(workerId, len)` (React Suspense streaming) or owns the
59
- // socket independently (SSE/WS). The argument is a JSON envelope
60
- // `{ route_id, path, params }` produced by Rust's route table see
68
+ // socket independently (SSE/WS). The argument is the INLINE JSON envelope
69
+ // `{ route_id, path, params }` produced by Rust's route table (the request
70
+ // always crosses as a string — SAB-request is closed) — see
61
71
  // runtime/routes.ts::RouteCall.
62
- export type RenderFn = (envelopeJsonOrLen: number | string) => Promise<number>
72
+ export type RenderFn = (envelopeJson: string, slot: number) => Promise<number>
63
73
 
64
74
  // Bun Workers run in the same OS process as the main thread; the `env` option
65
75
  // only patches the JS-visible process.env, not the native OS environment that
@@ -127,7 +137,16 @@ export const brust = {
127
137
  // leaving the prefix at its default — which broke custom-prefix routing.
128
138
  actionPrefix: opts.actionPrefix,
129
139
  })
130
- const baseEnv = { ...process.env }
140
+ // Render slots per worker. Propagated to each worker via env (Bun Workers
141
+ // share the OS process, so the worker reads it from process.env at
142
+ // registerRenderer time). Precedence: explicit `tuning.renderSlots`, else the
143
+ // `BRUST_RENDER_SLOTS` env (so it's configurable like BRUST_WORKERS without
144
+ // per-app wiring), else 1 — byte-identical single in-flight render.
145
+ const renderSlots = Math.max(
146
+ 1,
147
+ opts.tuning?.renderSlots ?? (Number(process.env.BRUST_RENDER_SLOTS) || 1),
148
+ )
149
+ const baseEnv = { ...process.env, BRUST_RENDER_SLOTS: String(renderSlots) }
131
150
  const workersArr: Worker[] = []
132
151
  for (let i = 0; i < opts.workers; i++) {
133
152
  // Bun.Worker requires the JS entry (post-bundling). For the skeleton,
@@ -142,9 +161,25 @@ export const brust = {
142
161
  const { registerInitialPool } = await import('./dev/worker-registry.ts')
143
162
  registerInitialPool(workersArr, opts.entry, opts.workers, baseEnv as Record<string, string>)
144
163
  }
145
- // Bun Workers intercept SIGINT before Rust's ctrl_c() handler fires.
146
- // Install a JS-level handler so the process actually exits on SIGINT.
147
- process.on('SIGINT', () => process.exit(0))
164
+ // Bun Workers intercept SIGINT before Rust's ctrl_c() handler fires, so a
165
+ // JS-level handler owns process exit. GRACEFUL DRAIN: stop accepting new
166
+ // connections, let in-flight renders/streams finish (bounded by the drain
167
+ // timeout), THEN exit — so a slow stream isn't cut off mid-response (the
168
+ // teardown that produced spurious `split_meta` errors under load). A second
169
+ // signal (impatient Ctrl-C) forces an immediate exit. SIGTERM too, for
170
+ // container orchestrators (Docker/k8s send SIGTERM on stop).
171
+ let draining = false
172
+ const drainTimeoutMs = Number(process.env.BRUST_DRAIN_TIMEOUT_MS) || 10_000
173
+ const gracefulExit = (code: number) => {
174
+ if (draining) process.exit(code)
175
+ draining = true
176
+ ;(native as any)
177
+ .beginDrain(drainTimeoutMs)
178
+ .catch(() => {})
179
+ .finally(() => process.exit(0))
180
+ }
181
+ process.on('SIGINT', () => gracefulExit(130))
182
+ process.on('SIGTERM', () => gracefulExit(143))
148
183
  await (native as any).untilReady(opts.bootTimeoutMs ?? 5000)
149
184
  await (native as any).untilShutdown()
150
185
  },
@@ -247,8 +282,8 @@ export const brust = {
247
282
  // (or any ArrayBuffer the worker keeps rooted) — Rust captures the backing-store
248
283
  // pointer once here and reuses it for every render call. The buffer is held alive
249
284
  // by the worker's module scope; do not let it go out of scope.
250
- registerRenderer(buf: Uint8Array, fn: RenderFn): number {
251
- return (native as any).registerRenderer(buf, fn)
285
+ registerRenderer(buf: Uint8Array, slots: number, fn: RenderFn): number {
286
+ return (native as any).registerRenderer(buf, slots, fn)
252
287
  },
253
288
 
254
289
  /**
@@ -724,7 +759,11 @@ export const brust = {
724
759
  }
725
760
  }
726
761
 
727
- const sab = new SharedArrayBuffer(opts.sabBytes ?? 256 * 1024)
762
+ // Render slots for this worker (set in serve() via the worker env). The
763
+ // SAB scales with the slot count so each slot's disjoint sub-region keeps
764
+ // the single-slot capacity; at K=1 this is byte-identical to before.
765
+ const renderSlots = Math.max(1, Number(process.env.BRUST_RENDER_SLOTS ?? 1) || 1)
766
+ const sab = new SharedArrayBuffer((opts.sabBytes ?? 256 * 1024) * renderSlots)
728
767
  const view = new Uint8Array(sab)
729
768
 
730
769
  let mcpManifest: import('./mcp/manifest.ts').McpManifest | null
@@ -766,8 +805,9 @@ export const brust = {
766
805
  actions: endpoints,
767
806
  getWorkerId: () => wid,
768
807
  mcp: mcpServer,
808
+ slots: renderSlots,
769
809
  })
770
- wid = this.registerRenderer(view, renderer)
810
+ wid = this.registerRenderer(view, renderSlots, renderer)
771
811
  }
772
812
  },
773
813
  }
@@ -1,4 +1,4 @@
1
- import { createElement, type ComponentType, type ReactNode } from 'react'
1
+ import { createContext, createElement, useContext, type ComponentType, type ReactNode } from 'react'
2
2
  import type { IsrConfig } from './isr-jsx.ts'
3
3
 
4
4
  /** Triggers that activate hydration of an island marker. */
@@ -44,18 +44,30 @@ export interface IslandProps<P> {
44
44
  isr?: IsrConfig
45
45
  }
46
46
 
47
- /** Module-scope flag flipped by every `<Island>` render. `makeRenderer`
48
- * reads + resets it once per render to decide whether to prepend the
49
- * importmap + bootstrap script. */
50
- let __used = false
47
+ /** Per-render box tracking whether any `<Island>` rendered. Created fresh per
48
+ * render and provided through {@link IslandUsedContext}; the renderer reads
49
+ * `box.used` once at the end to decide whether to prepend the importmap +
50
+ * bootstrap script.
51
+ *
52
+ * This is REQUEST-SCOPED (not a module-scope flag) so concurrent renders in one
53
+ * isolate (renderSlots>1) never cross-contaminate: React restores each render's
54
+ * context stack across Suspense resumption, so an interleaved peer setting its
55
+ * own box never flips ours. A module `let` could not give that guarantee. */
56
+ export interface IslandUsedBox {
57
+ used: boolean
58
+ }
51
59
 
52
- /** Internal flipped by Island, read by makeRenderer. */
53
- export function consumeIslandUsedFlag(): boolean {
54
- const v = __used
55
- __used = false
56
- return v
60
+ /** Fresh per-render box. */
61
+ export function createIslandUsedBox(): IslandUsedBox {
62
+ return { used: false }
57
63
  }
58
64
 
65
+ /** Carries the per-render {@link IslandUsedBox} down to every `<Island>`. The
66
+ * renderer wraps the tree in a Provider with a fresh box; an `<Island>` rendered
67
+ * with no Provider (e.g. a standalone `renderToString` outside the React render
68
+ * path) reads `null` and is a no-op. */
69
+ export const IslandUsedContext = createContext<IslandUsedBox | null>(null)
70
+
59
71
  // Constraint is `object`, NOT `Record<string, unknown>`: a TS `interface` has no
60
72
  // implicit index signature, so it does NOT satisfy `extends Record<string, unknown>`
61
73
  // — that made `P` fall back to the constraint and rejected any component whose
@@ -67,7 +79,10 @@ export function Island<P extends object>({
67
79
  props,
68
80
  hydrate = 'load',
69
81
  }: IslandProps<P>): ReactNode {
70
- __used = true
82
+ // Request-scoped: flip THIS render's box (see IslandUsedContext). No-op if
83
+ // rendered without a Provider (standalone renderToString).
84
+ const usedBox = useContext(IslandUsedContext)
85
+ if (usedBox) usedBox.used = true
71
86
  const resolvedId = Component.name
72
87
  if (!resolvedId) {
73
88
  throw new Error(
@@ -4,7 +4,7 @@
4
4
  import { renderToPipeableStream, renderToString } from 'react-dom/server.node'
5
5
  import { createElement, type ReactNode, type ComponentType } from 'react'
6
6
  import { Writable } from 'node:stream'
7
- import { consumeIslandUsedFlag } from '../islands/island.tsx'
7
+ import { IslandUsedContext, createIslandUsedBox } from '../islands/island.tsx'
8
8
  import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
9
9
  import { injectCssLink } from './inject-css-link.ts'
10
10
  import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
@@ -15,11 +15,26 @@ import { getDevClientSnippet } from '../dev/inject.ts'
15
15
 
16
16
  export interface RenderBranchStreamingArgs {
17
17
  element: ReactNode
18
+ /** The render's SAB sub-view (whole buffer at slots=1). All chunk encodes
19
+ * write at offset 0 of this view → automatically the slot's base. */
18
20
  view: Uint8Array
21
+ /** The render slot index, passed to every napi.renderChunk* call so Rust
22
+ * reads/writes the matching SAB sub-region. Default 0 (single-slot path). */
23
+ slot?: number
19
24
  workerId: bigint
20
25
  napi: {
21
- renderChunk: (workerId: bigint, len: number, sabBytes: Uint8Array) => Promise<void>
22
- renderChunkFinal: (workerId: bigint, len: number, sabBytes: Uint8Array) => Promise<void>
26
+ renderChunk: (
27
+ workerId: bigint,
28
+ slot: number,
29
+ len: number,
30
+ sabBytes: Uint8Array,
31
+ ) => Promise<void>
32
+ renderChunkFinal: (
33
+ workerId: bigint,
34
+ slot: number,
35
+ len: number,
36
+ sabBytes: Uint8Array,
37
+ ) => Promise<void>
23
38
  }
24
39
  errorBoundary: ComponentType<{ error: Error }>
25
40
  /** Status for the successful (non-error) render. Default 200. Used by
@@ -97,15 +112,18 @@ function concatBuffers(parts: Uint8Array[], withBootstrap: boolean): Uint8Array
97
112
 
98
113
  export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<void> {
99
114
  const { element, view, workerId, napi, errorBoundary } = args
115
+ const slot = args.slot ?? 0
100
116
  const successStatus = args.status ?? 200
101
117
  const extraHeaders = args.headers ?? {}
102
118
 
103
- // Reset the islands flag at the start of every render the streaming path
104
- // (which doesn't read the flag at the end) would otherwise leak its setting
105
- // to the next render. consumeIslandUsedFlag() reads-and-resets so calling
106
- // here is safe; the actual read for the buffering path happens at _final
107
- // time and sees only flips made during THIS render's React work.
108
- consumeIslandUsedFlag()
119
+ // Request-scoped islands signal: a fresh box per render, provided to every
120
+ // <Island> through IslandUsedContext. The buffering path reads `box.used` at
121
+ // _final to decide whether to prepend the importmap + bootstrap. Per-render
122
+ // (not a module flag) so concurrent renders in one isolate (renderSlots>1)
123
+ // never cross-contaminate React restores each render's context across
124
+ // Suspense resumption. No start-of-render reset needed: the box starts false.
125
+ const islandUsedBox = createIslandUsedBox()
126
+ const renderTree = createElement(IslandUsedContext.Provider, { value: islandUsedBox }, element)
109
127
 
110
128
  return new Promise<void>((resolve, reject) => {
111
129
  let finalSent = false
@@ -113,7 +131,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
113
131
  if (finalSent) return
114
132
  finalSent = true
115
133
  try {
116
- await napi.renderChunk(workerId, 0, view)
134
+ await napi.renderChunk(workerId, slot, 0, view)
117
135
  resolve()
118
136
  } catch (e) {
119
137
  reject(e)
@@ -139,7 +157,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
139
157
  // Wait for the header chunk to be flushed before sending body chunks.
140
158
  if (headerSent) await headerSent
141
159
  const len = encodeBodyChunk(view, chunk)
142
- await napi.renderChunk(workerId, len, view)
160
+ await napi.renderChunk(workerId, slot, len, view)
143
161
  }
144
162
  cb()
145
163
  } catch (e) {
@@ -149,7 +167,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
149
167
  async final(cb: (e?: Error | null) => void) {
150
168
  try {
151
169
  if (mode === 'buffering') {
152
- const islandsUsed = consumeIslandUsedFlag()
170
+ const islandsUsed = islandUsedBox.used
153
171
  let body = concatBuffers(buffer, islandsUsed)
154
172
  const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
155
173
  body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
@@ -162,7 +180,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
162
180
  headers: extraHeaders,
163
181
  })
164
182
  const len = encodeFirstChunk(view, meta, body)
165
- await napi.renderChunkFinal(workerId, len, view)
183
+ await napi.renderChunkFinal(workerId, slot, len, view)
166
184
  finalSent = true
167
185
  resolve()
168
186
  mode = 'done'
@@ -184,7 +202,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
184
202
  let allReadyFired = false
185
203
  let stream: ReturnType<typeof renderToPipeableStream>
186
204
  try {
187
- stream = renderToPipeableStream(element, {
205
+ stream = renderToPipeableStream(renderTree, {
188
206
  onShellReady() {
189
207
  // React fires onAllReady synchronously AFTER onShellReady in the
190
208
  // same microtask queue flush when there is no pending Suspense.
@@ -247,7 +265,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
247
265
  ;(async () => {
248
266
  try {
249
267
  const len = encodeFirstChunk(view, meta, flushed)
250
- await napi.renderChunk(workerId, len, view)
268
+ await napi.renderChunk(workerId, slot, len, view)
251
269
  resolveHeader()
252
270
  } catch (e) {
253
271
  rejectHeader(e)
@@ -267,7 +285,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
267
285
  ;(async () => {
268
286
  try {
269
287
  const len = encodeFirstChunk(view, meta, encoder.encode(html))
270
- await napi.renderChunkFinal(workerId, len, view)
288
+ await napi.renderChunkFinal(workerId, slot, len, view)
271
289
  finalSent = true
272
290
  resolve()
273
291
  } catch (e) {
@@ -285,7 +303,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
285
303
  ;(async () => {
286
304
  try {
287
305
  const len = encodeFirstChunk(view, meta, encoder.encode('Internal Server Error'))
288
- await napi.renderChunkFinal(workerId, len, view)
306
+ await napi.renderChunkFinal(workerId, slot, len, view)
289
307
  finalSent = true
290
308
  resolve()
291
309
  } catch (e) {
@@ -307,7 +325,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
307
325
  ;(async () => {
308
326
  try {
309
327
  const len = encodeFirstChunk(view, meta, encoder.encode('Internal Server Error'))
310
- await napi.renderChunkFinal(workerId, len, view)
328
+ await napi.renderChunkFinal(workerId, slot, len, view)
311
329
  finalSent = true
312
330
  resolve()
313
331
  } catch (ee) {