brustjs 0.1.0-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.
Files changed (63) hide show
  1. package/README.md +110 -0
  2. package/package.json +92 -0
  3. package/runtime/actions.ts +65 -0
  4. package/runtime/bun.lock +236 -0
  5. package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
  6. package/runtime/cli/build.ts +252 -0
  7. package/runtime/cli/dev.ts +92 -0
  8. package/runtime/cli/index.ts +30 -0
  9. package/runtime/cli/native-routes-emit.ts +171 -0
  10. package/runtime/cli/native-shim-plugin.ts +85 -0
  11. package/runtime/cli/new.ts +208 -0
  12. package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
  13. package/runtime/cli/templates/minimal/_gitignore +4 -0
  14. package/runtime/cli/templates/minimal/app.css +6 -0
  15. package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
  16. package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
  17. package/runtime/cli/templates/minimal/index.ts +4 -0
  18. package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
  19. package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
  20. package/runtime/cli/templates/minimal/routes.tsx +6 -0
  21. package/runtime/cli/templates/minimal/tsconfig.json +20 -0
  22. package/runtime/client/index.ts +121 -0
  23. package/runtime/config.ts +148 -0
  24. package/runtime/css/build.ts +54 -0
  25. package/runtime/css/component-build.ts +78 -0
  26. package/runtime/css/component-loader.ts +27 -0
  27. package/runtime/css/manifest.ts +51 -0
  28. package/runtime/css/process-modules.ts +56 -0
  29. package/runtime/css/route-deps.ts +33 -0
  30. package/runtime/css/scan-imports.ts +79 -0
  31. package/runtime/css.ts +39 -0
  32. package/runtime/dev/client.ts +49 -0
  33. package/runtime/dev/coordinator.ts +127 -0
  34. package/runtime/dev/inject.ts +17 -0
  35. package/runtime/dev/tui.ts +109 -0
  36. package/runtime/dev/watcher.ts +109 -0
  37. package/runtime/dev/worker-registry.ts +96 -0
  38. package/runtime/dev/ws-channel.ts +99 -0
  39. package/runtime/index.d.ts +199 -0
  40. package/runtime/index.js +604 -0
  41. package/runtime/index.ts +618 -0
  42. package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
  43. package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
  44. package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
  45. package/runtime/islands/_entries/react-dom.ts +7 -0
  46. package/runtime/islands/_entries/react.ts +11 -0
  47. package/runtime/islands/bootstrap.ts +241 -0
  48. package/runtime/islands/build.ts +141 -0
  49. package/runtime/islands/importmap.ts +17 -0
  50. package/runtime/islands/island.tsx +58 -0
  51. package/runtime/islands/native-render.ts +153 -0
  52. package/runtime/mcp/extractor.ts +160 -0
  53. package/runtime/mcp/manifest.ts +50 -0
  54. package/runtime/mcp/schema.ts +124 -0
  55. package/runtime/mcp/server.ts +250 -0
  56. package/runtime/render/inject-css-link.ts +59 -0
  57. package/runtime/render/inject-dev-client.ts +49 -0
  58. package/runtime/render/stream.ts +304 -0
  59. package/runtime/routes.ts +1406 -0
  60. package/runtime/scan-actions.ts +172 -0
  61. package/runtime/sse/handler.ts +85 -0
  62. package/runtime/tsconfig.json +14 -0
  63. package/runtime/ws/handler.ts +151 -0
@@ -0,0 +1,304 @@
1
+ // react-dom/server.node: React 19's Node-streams SSR build (renderToPipeableStream).
2
+ // Bun routes bare 'react-dom/server' to the web-streams build, which lacks it. Pin
3
+ // every react-dom/server import to .node so one build loads. See routes.ts for detail.
4
+ import { renderToPipeableStream, renderToString } from 'react-dom/server.node'
5
+ import { createElement, type ReactNode, type ComponentType } from 'react'
6
+ import { Writable } from 'node:stream'
7
+ import { consumeIslandUsedFlag } from '../islands/island.tsx'
8
+ import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
9
+ import { injectCssLink } from './inject-css-link.ts'
10
+ import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
11
+ import { injectDevClient } from './inject-dev-client.ts'
12
+ import { getDevClientSnippet } from '../dev/inject.ts'
13
+
14
+ export interface RenderBranchStreamingArgs {
15
+ element: ReactNode
16
+ view: Uint8Array
17
+ workerId: bigint
18
+ napi: {
19
+ renderChunk: (workerId: bigint, len: number, sabBytes: Uint8Array) => Promise<void>
20
+ renderChunkFinal: (workerId: bigint, len: number, sabBytes: Uint8Array) => Promise<void>
21
+ }
22
+ errorBoundary: ComponentType<{ error: Error }>
23
+ /** Status for the successful (non-error) render. Default 200. Used by
24
+ * middleware-wrapped routes that want to set a non-200 status before
25
+ * the component runs (e.g. 201 from a server action redirect). */
26
+ status?: number
27
+ /** Extra response headers injected by middleware (e.g. `x-render-ms`).
28
+ * Merged into the meta envelope's `headers` map. */
29
+ headers?: Record<string, string>
30
+ /** The matched route's fullPath (e.g. '/' or '/blog/{slug}'). Used to
31
+ * combine global CSS hrefs with per-route CSS hrefs before injection. */
32
+ routePath?: string
33
+ }
34
+
35
+ const encoder = new TextEncoder()
36
+
37
+ /** JSON.stringify the per-chunk meta. Defaults match the renderToString
38
+ * path so single-chunk responses keep their existing wire shape. */
39
+ export function makeMeta(opts: {
40
+ status: number
41
+ streaming: boolean
42
+ contentType?: string
43
+ headers?: Record<string, string>
44
+ }): string {
45
+ return JSON.stringify({
46
+ status: opts.status,
47
+ contentType: opts.contentType ?? 'text/html; charset=utf-8',
48
+ headers: opts.headers ?? {},
49
+ streaming: opts.streaming,
50
+ })
51
+ }
52
+
53
+ /** Encode `[meta_len: u16 BE][meta][body]` into the SAB at offset 0;
54
+ * return total byte length. Throws if it would exceed buf capacity. */
55
+ function encodeFirstChunk(view: Uint8Array, meta: string, body: Uint8Array): number {
56
+ const metaBytes = encoder.encode(meta)
57
+ const total = 2 + metaBytes.length + body.length
58
+ if (total > view.length) {
59
+ throw new Error(`first chunk ${total}b exceeds SAB ${view.length}b`)
60
+ }
61
+ view[0] = (metaBytes.length >> 8) & 0xff
62
+ view[1] = metaBytes.length & 0xff
63
+ view.set(metaBytes, 2)
64
+ view.set(body, 2 + metaBytes.length)
65
+ return total
66
+ }
67
+
68
+ function encodeBodyChunk(view: Uint8Array, body: Uint8Array): number {
69
+ if (body.length > view.length) {
70
+ throw new Error(`body chunk ${body.length}b exceeds SAB ${view.length}b`)
71
+ }
72
+ view.set(body, 0)
73
+ return body.length
74
+ }
75
+
76
+ function concatBuffers(parts: Uint8Array[], withBootstrap: boolean): Uint8Array {
77
+ const bootstrap = withBootstrap ? encoder.encode(ISLANDS_IMPORTMAP_AND_BOOTSTRAP) : null
78
+ const totalLen = (bootstrap?.length ?? 0) + parts.reduce((n, p) => n + p.length, 0)
79
+ const out = new Uint8Array(totalLen)
80
+ let off = 0
81
+ if (bootstrap) {
82
+ out.set(bootstrap, off)
83
+ off += bootstrap.length
84
+ }
85
+ for (const p of parts) {
86
+ out.set(p, off)
87
+ off += p.length
88
+ }
89
+ return out
90
+ }
91
+
92
+ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<void> {
93
+ const { element, view, workerId, napi, errorBoundary } = args
94
+ const successStatus = args.status ?? 200
95
+ const extraHeaders = args.headers ?? {}
96
+
97
+ // Reset the islands flag at the start of every render — the streaming path
98
+ // (which doesn't read the flag at the end) would otherwise leak its setting
99
+ // to the next render. consumeIslandUsedFlag() reads-and-resets so calling
100
+ // here is safe; the actual read for the buffering path happens at _final
101
+ // time and sees only flips made during THIS render's React work.
102
+ consumeIslandUsedFlag()
103
+
104
+ return new Promise<void>((resolve, reject) => {
105
+ let finalSent = false
106
+ const sendFinal = async () => {
107
+ if (finalSent) return
108
+ finalSent = true
109
+ try {
110
+ await napi.renderChunk(workerId, 0, view)
111
+ resolve()
112
+ } catch (e) {
113
+ reject(e)
114
+ }
115
+ }
116
+
117
+ let mode: 'buffering' | 'streaming' | 'done' = 'buffering'
118
+ const buffer: Uint8Array[] = []
119
+
120
+ // In streaming mode, body-chunk writes must wait for the header chunk
121
+ // to be fully sent first. This promise gates all sink.write calls.
122
+ let headerSent: Promise<void> | null = null
123
+
124
+ const sink = new Writable({
125
+ async write(chunk: Uint8Array, _enc: string, cb: (e?: Error | null) => void) {
126
+ try {
127
+ if (mode === 'buffering') {
128
+ buffer.push(new Uint8Array(chunk))
129
+ cb()
130
+ return
131
+ }
132
+ if (mode === 'streaming') {
133
+ // Wait for the header chunk to be flushed before sending body chunks.
134
+ if (headerSent) await headerSent
135
+ const len = encodeBodyChunk(view, chunk)
136
+ await napi.renderChunk(workerId, len, view)
137
+ }
138
+ cb()
139
+ } catch (e) {
140
+ cb(e as Error)
141
+ }
142
+ },
143
+ async final(cb: (e?: Error | null) => void) {
144
+ try {
145
+ if (mode === 'buffering') {
146
+ const islandsUsed = consumeIslandUsedFlag()
147
+ let body = concatBuffers(buffer, islandsUsed)
148
+ const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
149
+ body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
150
+ body = injectDevClient(body, getDevClientSnippet())
151
+ const meta = makeMeta({
152
+ status: successStatus,
153
+ streaming: false,
154
+ headers: extraHeaders,
155
+ })
156
+ const len = encodeFirstChunk(view, meta, body)
157
+ await napi.renderChunkFinal(workerId, len, view)
158
+ finalSent = true
159
+ resolve()
160
+ mode = 'done'
161
+ } else if (mode === 'streaming') {
162
+ if (headerSent) await headerSent
163
+ await sendFinal()
164
+ mode = 'done'
165
+ }
166
+ cb()
167
+ } catch (e) {
168
+ cb(e as Error)
169
+ }
170
+ },
171
+ })
172
+ sink.on('error', reject)
173
+
174
+ // Whether onAllReady has fired — set synchronously by React when there
175
+ // is no pending Suspense (fires in the same tick as onShellReady).
176
+ let allReadyFired = false
177
+ let stream: ReturnType<typeof renderToPipeableStream>
178
+ try {
179
+ stream = renderToPipeableStream(element, {
180
+ onShellReady() {
181
+ // React fires onAllReady synchronously AFTER onShellReady in the
182
+ // same microtask queue flush when there is no pending Suspense.
183
+ // We defer our decision by one microtask so onAllReady has a
184
+ // chance to run first.
185
+ queueMicrotask(() => {
186
+ if (allReadyFired) {
187
+ // No pending Suspense — buffering path: pipe now so Writable
188
+ // _final assembles the single chunk.
189
+ stream.pipe(sink)
190
+ return
191
+ }
192
+ // onAllReady hasn't fired yet → pending Suspense → streaming path.
193
+ mode = 'streaming'
194
+ let flushed = concatBuffers(buffer, true)
195
+ buffer.length = 0
196
+ // Streaming-only placement note: in the buffering branch we splice
197
+ // <link> + dev <script> immediately before </head> (which is in the
198
+ // accumulated body bytes). In streaming we can't — the first chunk
199
+ // here is just the bootstrap prepend; </head> arrives in a later
200
+ // React chunk that bypasses injection entirely. So we append the
201
+ // link + dev tags after the bootstrap, before React's <!DOCTYPE>.
202
+ // Browsers fetch the stylesheets and execute the script regardless
203
+ // of position; quirks-mode is already engaged by the bootstrap
204
+ // script's position so no new penalty.
205
+ const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
206
+ const streamHrefs = [...getCssHrefs(), ...perRouteHrefs]
207
+ const linkTagsStr = streamHrefs
208
+ .map((h) => `<link rel="stylesheet" href="${h}">`)
209
+ .join('')
210
+ const devTag = getDevClientSnippet() ?? ''
211
+ if (linkTagsStr.length > 0 || devTag.length > 0) {
212
+ const prepend = encoder.encode(linkTagsStr + devTag)
213
+ const out = new Uint8Array(flushed.length + prepend.length)
214
+ out.set(flushed, 0)
215
+ out.set(prepend, flushed.length)
216
+ flushed = out
217
+ }
218
+ const meta = makeMeta({ status: successStatus, streaming: true, headers: extraHeaders })
219
+ // Send header chunk and pipe concurrently — writes gate on headerSent.
220
+ let resolveHeader!: () => void
221
+ let rejectHeader!: (e: unknown) => void
222
+ headerSent = new Promise<void>((res, rej) => {
223
+ resolveHeader = res
224
+ rejectHeader = rej
225
+ })
226
+ // Attach a no-op catch so a synchronous rejection from the IIFE doesn't
227
+ // fire Node's unhandledRejection before a downstream await subscribes.
228
+ // The actual error still flows through reject() of the outer Promise.
229
+ headerSent.catch(() => {})
230
+ // Pipe immediately so React can keep flushing resolved Suspense data.
231
+ stream.pipe(sink)
232
+ ;(async () => {
233
+ try {
234
+ const len = encodeFirstChunk(view, meta, flushed)
235
+ await napi.renderChunk(workerId, len, view)
236
+ resolveHeader()
237
+ } catch (e) {
238
+ rejectHeader(e)
239
+ reject(e)
240
+ }
241
+ })()
242
+ })
243
+ },
244
+ onAllReady() {
245
+ allReadyFired = true
246
+ },
247
+ onShellError(err) {
248
+ try {
249
+ const html = renderToString(createElement(errorBoundary, { error: err as Error }))
250
+ const meta = makeMeta({ status: 500, streaming: false })
251
+ mode = 'done'
252
+ ;(async () => {
253
+ try {
254
+ const len = encodeFirstChunk(view, meta, encoder.encode(html))
255
+ await napi.renderChunkFinal(workerId, len, view)
256
+ finalSent = true
257
+ resolve()
258
+ } catch (e) {
259
+ reject(e)
260
+ }
261
+ })()
262
+ } catch (e2) {
263
+ console.error('[brust] errorBoundary threw during shell error:', e2)
264
+ const meta = makeMeta({
265
+ status: 500,
266
+ streaming: false,
267
+ contentType: 'text/plain; charset=utf-8',
268
+ })
269
+ mode = 'done'
270
+ ;(async () => {
271
+ try {
272
+ const len = encodeFirstChunk(view, meta, encoder.encode('Internal Server Error'))
273
+ await napi.renderChunkFinal(workerId, len, view)
274
+ finalSent = true
275
+ resolve()
276
+ } catch (e) {
277
+ reject(e)
278
+ }
279
+ })()
280
+ }
281
+ },
282
+ onError(err) {
283
+ console.error('[brust] render onError (post-shell):', err)
284
+ },
285
+ })
286
+ } catch (_e) {
287
+ const meta = makeMeta({
288
+ status: 500,
289
+ streaming: false,
290
+ contentType: 'text/plain; charset=utf-8',
291
+ })
292
+ ;(async () => {
293
+ try {
294
+ const len = encodeFirstChunk(view, meta, encoder.encode('Internal Server Error'))
295
+ await napi.renderChunkFinal(workerId, len, view)
296
+ finalSent = true
297
+ resolve()
298
+ } catch (ee) {
299
+ reject(ee)
300
+ }
301
+ })()
302
+ }
303
+ })
304
+ }