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,172 @@
1
+ import { relative } from 'node:path'
2
+ import type { ActionDef, ActionFn } from './actions.ts'
3
+ import { isValidActionId, getActionMiddleware } from './actions.ts'
4
+
5
+ const DIRECTIVE_HEAD_BYTES = 512
6
+
7
+ /** Remove leading whitespace, line comments (`//`), and block comments
8
+ * from `src` and return the rest. Stops at the first non-trivial character.
9
+ * Does NOT understand string literals — fine because we only run this on a
10
+ * directive prologue, which by spec contains comments and the directive only.
11
+ * If a block comment never terminates, returns '' so the caller treats the
12
+ * file as non-server. */
13
+ export function stripLeadingTrivia(src: string): string {
14
+ let i = 0
15
+ while (i < src.length) {
16
+ const ch = src[i]
17
+ if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
18
+ i++
19
+ continue
20
+ }
21
+ if (ch === '/' && src[i + 1] === '/') {
22
+ const nl = src.indexOf('\n', i)
23
+ if (nl === -1) return ''
24
+ i = nl + 1
25
+ continue
26
+ }
27
+ if (ch === '/' && src[i + 1] === '*') {
28
+ const end = src.indexOf('*/', i + 2)
29
+ if (end === -1) return ''
30
+ i = end + 2
31
+ continue
32
+ }
33
+ break
34
+ }
35
+ return src.slice(i)
36
+ }
37
+
38
+ const USE_SERVER_PATTERN = /^(?:'use server'|"use server")\s*;?\s*(?:\r?\n|$)/
39
+
40
+ /** Read the first 512 bytes of `filePath` and return true iff a file-level
41
+ * `'use server'` directive sits at the prologue position (before any import
42
+ * or other statement). Comments and whitespace ahead of the directive are
43
+ * skipped. Mirrors the TC39 directive-prologue rule. */
44
+ export async function hasUseServerDirective(filePath: string): Promise<boolean> {
45
+ const f = Bun.file(filePath)
46
+ const head = await f.slice(0, DIRECTIVE_HEAD_BYTES).text()
47
+ const stripped = stripLeadingTrivia(head)
48
+ return USE_SERVER_PATTERN.test(stripped)
49
+ }
50
+
51
+ /** Dynamically import `filePath` and collect every named function export as
52
+ * an ActionDef. Skips non-function exports silently. Throws on:
53
+ * - default export (must be named)
54
+ * - class export (calling a class without `new` would 500 at dispatch)
55
+ * - invalid id charset
56
+ * - zero function exports (likely a bug — file marked 'use server' but
57
+ * publishes nothing).
58
+ * Middleware metadata installed by withMiddleware is preserved. */
59
+ export async function collectExports(filePath: string): Promise<ActionDef[]> {
60
+ const mod = (await import(filePath)) as Record<string, unknown>
61
+ const defs: ActionDef[] = []
62
+ for (const [name, value] of Object.entries(mod)) {
63
+ if (typeof value !== 'function') continue
64
+ // typeof class{} is 'function' in JS; reject explicitly so an accidental
65
+ // `export class Foo {}` in a 'use server' file fails loudly at scan,
66
+ // not with a confusing 500 at dispatch.
67
+ if (Function.prototype.toString.call(value).startsWith('class ')) {
68
+ throw new Error(
69
+ `${filePath}: export "${name}" is a class. Actions must be plain async functions, not class constructors.`,
70
+ )
71
+ }
72
+ if (name === 'default') {
73
+ throw new Error(`${filePath}: default exports are not action-eligible. Use named export.`)
74
+ }
75
+ if (!isValidActionId(name)) {
76
+ throw new Error(
77
+ `${filePath}: export "${name}" has invalid id (must match [A-Za-z0-9_-]+, 1-128 chars).`,
78
+ )
79
+ }
80
+ defs.push({
81
+ id: name,
82
+ fn: value as ActionFn,
83
+ middleware: getActionMiddleware(value),
84
+ })
85
+ }
86
+ if (defs.length === 0) {
87
+ throw new Error(`${filePath}: marked 'use server' but exports no functions.`)
88
+ }
89
+ return defs
90
+ }
91
+
92
+ export interface ScanOptions {
93
+ /** Glob roots to scan from. Default: ['./']. Pass an explicit root (e.g.
94
+ * `import.meta.dirname`) when the project layout includes sibling subtrees
95
+ * you don't want scanned — typical for example apps inside a larger repo. */
96
+ roots?: string[]
97
+ /** Ignore globs (matched against the path relative to each root). Override
98
+ * the default array if you need a different policy — there's no merge.
99
+ * Default covers build outputs and test patterns. */
100
+ ignore?: string[]
101
+ }
102
+
103
+ const DEFAULT_IGNORE = Object.freeze([
104
+ 'node_modules/**',
105
+ '.brust/**',
106
+ 'dist/**',
107
+ 'build/**',
108
+ 'tests/**',
109
+ '__tests__/**',
110
+ '*.test.ts',
111
+ '*.test.tsx',
112
+ '*.spec.ts',
113
+ '*.spec.tsx',
114
+ ])
115
+
116
+ const FILE_PATTERN = '**/*.{ts,tsx,js,jsx,mjs,cjs}'
117
+
118
+ async function findCandidateFiles(opts: ScanOptions): Promise<string[]> {
119
+ const roots = opts.roots ?? ['./']
120
+ const ignore = opts.ignore ?? [...DEFAULT_IGNORE]
121
+ const ignoreGlobs = ignore.map((p) => new Bun.Glob(p))
122
+ const out: string[] = []
123
+ for (const root of roots) {
124
+ const glob = new Bun.Glob(FILE_PATTERN)
125
+ for await (const f of glob.scan({ cwd: root, dot: false, absolute: true })) {
126
+ const rel = relative(root, f)
127
+ if (ignoreGlobs.some((g) => g.match(rel))) continue
128
+ out.push(f)
129
+ }
130
+ }
131
+ return out
132
+ }
133
+
134
+ export interface ScanActionsResult {
135
+ actions: ActionDef[]
136
+ /** Absolute paths of files that had a `'use server'` directive — needed by
137
+ * `brust.buildMcpManifest` to feed the TypeScript compiler API extractor. */
138
+ sourceFiles: string[]
139
+ }
140
+
141
+ /** Walk the project, find files whose first statement is `'use server'`,
142
+ * import each, and return all named function exports as ActionDef[] plus the
143
+ * list of server source files.
144
+ * Throws on duplicate ids across files. Always returns actions sorted by id
145
+ * for deterministic logging. */
146
+ export async function scanActions(opts: ScanOptions = {}): Promise<ScanActionsResult> {
147
+ const candidates = await findCandidateFiles(opts)
148
+ // Run directive checks in parallel — file IO scales well there.
149
+ const directiveChecks = await Promise.all(
150
+ candidates.map(async (p) => ({ path: p, isServer: await hasUseServerDirective(p) })),
151
+ )
152
+ const serverFiles = directiveChecks.filter((c) => c.isServer).map((c) => c.path)
153
+
154
+ // Serial imports — heavy module side effects shouldn't all fire at once.
155
+ const byId = new Map<string, string>()
156
+ const all: ActionDef[] = []
157
+ for (const file of serverFiles) {
158
+ const defs = await collectExports(file)
159
+ for (const def of defs) {
160
+ const prior = byId.get(def.id)
161
+ if (prior) {
162
+ throw new Error(
163
+ `Duplicate action "${def.id}" — defined in both ${prior} and ${file}. Rename one.`,
164
+ )
165
+ }
166
+ byId.set(def.id, file)
167
+ all.push(def)
168
+ }
169
+ }
170
+ all.sort((a, b) => a.id.localeCompare(b.id))
171
+ return { actions: all, sourceFiles: serverFiles }
172
+ }
@@ -0,0 +1,85 @@
1
+ import type { Route, BrustRequest, RouteCall } from '../routes.ts'
2
+
3
+ export type SseCall = Extract<RouteCall, { kind: 'sse' }>
4
+
5
+ /** NAPI surface — Rust provides these. In tests, a mock satisfies the shape. */
6
+ export interface SseNapi {
7
+ write(conn_id: bigint, bytes: Uint8Array): Promise<void>
8
+ close(conn_id: bigint): void
9
+ registerAbort(conn_id: bigint, cb: () => void): void
10
+ signalOpen(conn_id: bigint, status: number, body: string, contentType: string): void
11
+ }
12
+
13
+ const encoder = new TextEncoder()
14
+ const PING_FRAME = encoder.encode(': ping\n\n')
15
+
16
+ /**
17
+ * Per-connection JS driver for SSE routes. Called once per client connection.
18
+ *
19
+ * Contract with the Rust napi shim:
20
+ * - napi.registerAbort(conn_id, cb) — Rust calls cb when client disconnects
21
+ * - napi.signalOpen(conn_id, status, body, contentType)
22
+ * — JS reports verdict; Rust waits before writing headers
23
+ * - napi.write(conn_id, bytes) — write a chunk; Promise resolves when TCP write completes (backpressure)
24
+ * - napi.close(conn_id) — JS tells Rust to tear down the per-conn task
25
+ */
26
+ export async function handleSseStream(call: SseCall, route: Route, napi: SseNapi): Promise<void> {
27
+ // 1. Create per-conn AbortController + a fresh request object whose signal
28
+ // points at it. We spread call.req rather than mutating in place so the
29
+ // underlying envelope object (potentially reused by makeRenderer in the
30
+ // future) is never modified — the SSE-specific signal lives on a copy.
31
+ const controller = new AbortController()
32
+ const req: BrustRequest = { ...call.req, signal: controller.signal }
33
+
34
+ // 2. Wire Rust→JS abort.
35
+ napi.registerAbort(call.conn_id, () => controller.abort())
36
+
37
+ // 3. Open the user's stream. Throw → signal 500 and close.
38
+ let stream: ReadableStream<Uint8Array | string>
39
+ try {
40
+ stream = await route.sse!(req)
41
+ } catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err)
43
+ napi.signalOpen(call.conn_id, 500, `sse handler threw: ${msg}`, 'text/plain; charset=utf-8')
44
+ napi.close(call.conn_id)
45
+ return
46
+ }
47
+
48
+ // 4. Signal open OK — Rust writes SSE response headers.
49
+ napi.signalOpen(call.conn_id, 200, '', 'text/event-stream')
50
+
51
+ // 5. Heartbeat + reader loop.
52
+ const reader = stream.getReader()
53
+ const heartbeatMs = route.sseOptions?.heartbeatMs ?? 15_000
54
+ const heartbeatId =
55
+ heartbeatMs > 0
56
+ ? setInterval(() => {
57
+ void napi.write(call.conn_id, PING_FRAME)
58
+ }, heartbeatMs)
59
+ : null
60
+
61
+ // Force-cancel reader on abort so a stuck `await reader.read()` unwinds
62
+ // even if the author didn't listen to req.signal.
63
+ controller.signal.addEventListener('abort', () => {
64
+ void reader.cancel()
65
+ })
66
+
67
+ try {
68
+ while (true) {
69
+ const { value, done } = await reader.read()
70
+ if (done) break
71
+ const bytes = typeof value === 'string' ? encoder.encode(value) : value
72
+ await napi.write(call.conn_id, bytes)
73
+ }
74
+ } catch (err) {
75
+ if (!controller.signal.aborted) {
76
+ console.error(`[brust] sse stream error conn=${call.conn_id}:`, err)
77
+ }
78
+ } finally {
79
+ if (heartbeatId !== null) clearInterval(heartbeatId)
80
+ napi.close(call.conn_id)
81
+ try {
82
+ reader.releaseLock()
83
+ } catch {}
84
+ }
85
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "esModuleInterop": true,
10
+ "allowImportingTsExtensions": true,
11
+ "noEmit": true
12
+ },
13
+ "include": ["**/*.ts", "**/*.tsx"]
14
+ }
@@ -0,0 +1,151 @@
1
+ import type { Route, RouteCall, WsHandlers, WsSocket } from '../routes.ts'
2
+
3
+ export type WsCall = Extract<RouteCall, { kind: 'ws' }>
4
+
5
+ /** NAPI surface — Rust provides these. Tests use a mock. */
6
+ export interface WsNapi {
7
+ send(conn_id: bigint, data: Uint8Array, isBinary: boolean): Promise<void>
8
+ close(conn_id: bigint, code: number, reason: string): void
9
+ signalOpen(
10
+ conn_id: bigint,
11
+ status: number,
12
+ body: string,
13
+ contentType: string,
14
+ subprotocol: string,
15
+ ): void
16
+ registerHandlers(
17
+ conn_id: bigint,
18
+ onMessage: (data: Uint8Array, isBinary: boolean) => void,
19
+ onClose: (code: number, reason: string) => void,
20
+ ): void
21
+ }
22
+
23
+ /** Pick the first subprotocol from `routeList` that the client also requested.
24
+ * Returns null when there's no overlap or routeList is empty/undefined. */
25
+ export function pickSubprotocol(
26
+ clientList: string[],
27
+ routeList: string[] | undefined,
28
+ ): string | null {
29
+ if (!routeList || routeList.length === 0) return null
30
+ for (const candidate of routeList) {
31
+ if (clientList.includes(candidate)) return candidate
32
+ }
33
+ return null
34
+ }
35
+
36
+ class WsSocketImpl implements WsSocket {
37
+ constructor(
38
+ public readonly id: bigint,
39
+ private napi: WsNapi,
40
+ private closed: { v: boolean },
41
+ ) {}
42
+
43
+ async send(data: string | Uint8Array): Promise<void> {
44
+ if (this.closed.v) throw new Error(`ws conn ${this.id}: already closed`)
45
+ const bytes = typeof data === 'string' ? encoder.encode(data) : data
46
+ const isBinary = typeof data !== 'string'
47
+ await this.napi.send(this.id, bytes, isBinary)
48
+ }
49
+
50
+ close(code: number = 1000, reason: string = ''): void {
51
+ if (this.closed.v) return
52
+ this.closed.v = true
53
+ this.napi.close(this.id, code, reason.slice(0, 123))
54
+ }
55
+ }
56
+
57
+ const decoder = new TextDecoder('utf-8')
58
+ const encoder = new TextEncoder()
59
+
60
+ /**
61
+ * Per-connection JS driver for WebSocket routes. Caller (wsBranch) is
62
+ * responsible for running middleware FIRST and only invoking this on a 101
63
+ * verdict — the signalOpen call here is always 101.
64
+ *
65
+ * Ordering note: handlers.open is *called* before any message can fire,
66
+ * but if open is async it is NOT guaranteed to *complete* before the first
67
+ * message arrives. Handlers that initialise per-connection state in open
68
+ * and read it in message should await any setup synchronously inside open,
69
+ * or guard reads in message against missing state.
70
+ */
71
+ export async function handleWsConn(call: WsCall, route: Route, napi: WsNapi): Promise<void> {
72
+ // Load the handler module. Failure here → 500 signalOpen (Rust writes
73
+ // regular HTTP error response since 101 hasn't been sent yet).
74
+ //
75
+ // Accept either a direct WsHandlers object OR a module wrapper exposing
76
+ // the handlers via `default`. The latter is what `() => import('./x.ts')`
77
+ // produces, which is the most common author pattern — refusing it would
78
+ // silently no-op the handler (no method on the module-namespace object
79
+ // matches WsHandlers' method names).
80
+ let handlers: WsHandlers
81
+ try {
82
+ const loaded = await route.websocket!()
83
+ const maybeDefault = (loaded as { default?: WsHandlers }).default
84
+ handlers =
85
+ maybeDefault && typeof maybeDefault === 'object' ? maybeDefault : (loaded as WsHandlers)
86
+ } catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err)
88
+ napi.signalOpen(
89
+ call.conn_id,
90
+ 500,
91
+ `ws handler import failed: ${msg}`,
92
+ 'text/plain; charset=utf-8',
93
+ '',
94
+ )
95
+ return
96
+ }
97
+
98
+ // Pick subprotocol (route declares; client requests; first match wins).
99
+ const chosen = pickSubprotocol(call.client_subprotocols, route.wsOptions?.subprotocols)
100
+
101
+ // CRITICAL ORDERING: register handlers BEFORE signalOpen(101). Once
102
+ // signalOpen fires, Rust writes the 101 response, the client sees
103
+ // OPEN immediately, and may send frames that hit ws_conn_task's
104
+ // on_message lookup. If on_message is still None at that point the
105
+ // frame is silently dropped. Build the socket + install handlers
106
+ // first; only then unblock Rust by signalling 101.
107
+ const closed = { v: false }
108
+ const socket = new WsSocketImpl(call.conn_id, napi, closed)
109
+
110
+ napi.registerHandlers(
111
+ call.conn_id,
112
+ (data, isBinary) => {
113
+ const payload = isBinary ? data : decoder.decode(data)
114
+ try {
115
+ const r = handlers.message?.(socket, payload)
116
+ if (r instanceof Promise)
117
+ r.catch((err) => {
118
+ console.error(`[brust] ws conn=${call.conn_id} message handler rejected:`, err)
119
+ })
120
+ } catch (err) {
121
+ console.error(`[brust] ws conn=${call.conn_id} message handler threw:`, err)
122
+ }
123
+ },
124
+ (code, reason) => {
125
+ closed.v = true
126
+ try {
127
+ handlers.close?.(socket, code, reason)
128
+ } catch (err) {
129
+ console.error(`[brust] ws conn=${call.conn_id} close handler threw:`, err)
130
+ }
131
+ },
132
+ )
133
+
134
+ napi.signalOpen(call.conn_id, 101, '', '', chosen ?? '')
135
+
136
+ // Fire author's open hook. Throws here close the conn 1011.
137
+ if (handlers.open) {
138
+ try {
139
+ const r = handlers.open(socket, { req: call.req, subprotocol: chosen })
140
+ if (r instanceof Promise) {
141
+ r.catch((err) => {
142
+ console.error(`[brust] ws conn=${call.conn_id} open handler rejected:`, err)
143
+ if (!closed.v) socket.close(1011, 'internal error')
144
+ })
145
+ }
146
+ } catch (err) {
147
+ console.error(`[brust] ws conn=${call.conn_id} open handler threw:`, err)
148
+ if (!closed.v) socket.close(1011, 'internal error')
149
+ }
150
+ }
151
+ }