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.
- package/README.md +110 -0
- package/package.json +92 -0
- package/runtime/actions.ts +65 -0
- package/runtime/bun.lock +236 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
- package/runtime/cli/build.ts +252 -0
- package/runtime/cli/dev.ts +92 -0
- package/runtime/cli/index.ts +30 -0
- package/runtime/cli/native-routes-emit.ts +171 -0
- package/runtime/cli/native-shim-plugin.ts +85 -0
- package/runtime/cli/new.ts +208 -0
- package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
- package/runtime/cli/templates/minimal/_gitignore +4 -0
- package/runtime/cli/templates/minimal/app.css +6 -0
- package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
- package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
- package/runtime/cli/templates/minimal/index.ts +4 -0
- package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
- package/runtime/cli/templates/minimal/routes.tsx +6 -0
- package/runtime/cli/templates/minimal/tsconfig.json +20 -0
- package/runtime/client/index.ts +121 -0
- package/runtime/config.ts +148 -0
- package/runtime/css/build.ts +54 -0
- package/runtime/css/component-build.ts +78 -0
- package/runtime/css/component-loader.ts +27 -0
- package/runtime/css/manifest.ts +51 -0
- package/runtime/css/process-modules.ts +56 -0
- package/runtime/css/route-deps.ts +33 -0
- package/runtime/css/scan-imports.ts +79 -0
- package/runtime/css.ts +39 -0
- package/runtime/dev/client.ts +49 -0
- package/runtime/dev/coordinator.ts +127 -0
- package/runtime/dev/inject.ts +17 -0
- package/runtime/dev/tui.ts +109 -0
- package/runtime/dev/watcher.ts +109 -0
- package/runtime/dev/worker-registry.ts +96 -0
- package/runtime/dev/ws-channel.ts +99 -0
- package/runtime/index.d.ts +199 -0
- package/runtime/index.js +604 -0
- package/runtime/index.ts +618 -0
- package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
- package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
- package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
- package/runtime/islands/_entries/react-dom.ts +7 -0
- package/runtime/islands/_entries/react.ts +11 -0
- package/runtime/islands/bootstrap.ts +241 -0
- package/runtime/islands/build.ts +141 -0
- package/runtime/islands/importmap.ts +17 -0
- package/runtime/islands/island.tsx +58 -0
- package/runtime/islands/native-render.ts +153 -0
- package/runtime/mcp/extractor.ts +160 -0
- package/runtime/mcp/manifest.ts +50 -0
- package/runtime/mcp/schema.ts +124 -0
- package/runtime/mcp/server.ts +250 -0
- package/runtime/render/inject-css-link.ts +59 -0
- package/runtime/render/inject-dev-client.ts +49 -0
- package/runtime/render/stream.ts +304 -0
- package/runtime/routes.ts +1406 -0
- package/runtime/scan-actions.ts +172 -0
- package/runtime/sse/handler.ts +85 -0
- package/runtime/tsconfig.json +14 -0
- 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
|
+
}
|