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,78 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import path from 'node:path'
4
+ import { scanCssImports } from './scan-imports.ts'
5
+ import { processCssFile } from './process-modules.ts'
6
+ import { computeRouteChunks, type RouteForCss } from './route-deps.ts'
7
+ import { writeComponentCssManifest, type ComponentCssManifest } from './manifest.ts'
8
+
9
+ export interface BuildComponentCssOptions {
10
+ /** Absolute path to the user's app dir (where Home.tsx lives). */
11
+ scanRoot: string
12
+ /** Absolute path under which chunk files + manifest are written. */
13
+ outDir: string
14
+ /** Optional Tailwind compiler (for @apply resolution). */
15
+ tailwindCompile: Parameters<typeof processCssFile>[0]['tailwindCompile']
16
+ /** Routes to map to CSS chunks. */
17
+ routes: RouteForCss[]
18
+ }
19
+
20
+ /** Run the full component CSS pipeline: scan → process → emit chunks +
21
+ * .d.ts + manifest. Returns the in-memory manifest for callers (brust.run)
22
+ * that need to register the Bun.plugin immediately. */
23
+ export async function buildComponentCss(
24
+ opts: BuildComponentCssOptions,
25
+ ): Promise<ComponentCssManifest> {
26
+ const scan = await scanCssImports(opts.scanRoot)
27
+
28
+ // Collect unique CSS file paths (a file may be imported from multiple .tsx).
29
+ const cssFiles = new Map<string, { isModule: boolean }>()
30
+ for (const deps of scan.values()) {
31
+ for (const d of deps) cssFiles.set(d.path, { isModule: d.isModule })
32
+ }
33
+
34
+ const chunksDir = path.join(opts.outDir, 'components')
35
+ await mkdir(chunksDir, { recursive: true })
36
+
37
+ const modules: ComponentCssManifest['modules'] = {}
38
+ for (const [absPath, { isModule }] of cssFiles) {
39
+ const source = await readFile(absPath, 'utf-8')
40
+ const result = await processCssFile({
41
+ filename: absPath,
42
+ source,
43
+ isModule,
44
+ tailwindCompile: opts.tailwindCompile,
45
+ })
46
+ // Deterministic chunk filename: sha256 of relative path. relPath alone
47
+ // is enough for identity; content changes would update file mtime but
48
+ // not the chunk path (stable URL → browser cache reuse).
49
+ const rel = path.relative(opts.scanRoot, absPath)
50
+ const hash = createHash('sha256').update(rel).digest('hex').slice(0, 8)
51
+ const chunkName = `${hash}.css`
52
+ await writeFile(path.join(chunksDir, chunkName), result.code)
53
+ modules[absPath] = {
54
+ chunk: `/_brust/css/components/${chunkName}`,
55
+ exports: result.exports,
56
+ }
57
+ if (isModule) {
58
+ const lines = ['declare const styles: {']
59
+ for (const k of Object.keys(result.exports ?? {})) {
60
+ lines.push(` readonly ${k}: string`)
61
+ }
62
+ lines.push('}', 'export default styles', '')
63
+ await writeFile(absPath + '.d.ts', lines.join('\n'), 'utf-8')
64
+ }
65
+ }
66
+
67
+ // Convert modules map into shape expected by computeRouteChunks.
68
+ const lookup: Record<string, { chunk: string }> = {}
69
+ for (const [p, m] of Object.entries(modules)) lookup[p] = { chunk: m.chunk }
70
+ const routeChunks = computeRouteChunks(opts.routes, scan, lookup)
71
+
72
+ const manifest: ComponentCssManifest = { version: 1, modules, routeChunks }
73
+ await writeComponentCssManifest(
74
+ path.join(opts.outDir, 'css', 'component-manifest.json'),
75
+ manifest,
76
+ )
77
+ return manifest
78
+ }
@@ -0,0 +1,27 @@
1
+ import type { BunPlugin } from 'bun'
2
+ import type { ComponentCssManifest } from './manifest.ts'
3
+
4
+ /** Build a Bun.plugin that resolves component CSS imports.
5
+ * - .module.css → `export default <name-map>` (JS, baked from manifest)
6
+ * - .css → empty JS (side-effect; real CSS already on disk)
7
+ *
8
+ * Register once per isolate (brust.run main + each worker). */
9
+ export function cssLoaderPlugin(manifest: ComponentCssManifest): BunPlugin {
10
+ return {
11
+ name: 'brust-component-css',
12
+ setup(build) {
13
+ build.onLoad({ filter: /\.module\.css$/ }, ({ path }) => {
14
+ const mod = manifest.modules[path]
15
+ const exports = mod?.exports ?? {}
16
+ return {
17
+ contents: `export default ${JSON.stringify(exports)}`,
18
+ loader: 'js',
19
+ }
20
+ })
21
+ build.onLoad({ filter: /\.css$/ }, () => ({
22
+ contents: '',
23
+ loader: 'js',
24
+ }))
25
+ },
26
+ }
27
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ /** Per-CSS-file entry. Side-effect imports (.css) have exports:null;
5
+ * CSS Modules (.module.css) have a flat name→hashed-name map. */
6
+ export interface ComponentCssModuleEntry {
7
+ /** Absolute URL path served by Rust (e.g. /_brust/css/components/<sha>.css). */
8
+ chunk: string
9
+ /** Original class name → hashed class name. null for non-module .css. */
10
+ exports: Record<string, string> | null
11
+ }
12
+
13
+ export interface ComponentCssManifest {
14
+ version: 1
15
+ /** Absolute filesystem path of the source .css file → entry. */
16
+ modules: Record<string, ComponentCssModuleEntry>
17
+ /** Route.fullPath → ordered, deduplicated chunk href list. */
18
+ routeChunks: Record<string, string[]>
19
+ }
20
+
21
+ /** Read + validate. Returns null when the file doesn't exist (project has
22
+ * no component CSS — treated as a no-op everywhere). Throws on malformed
23
+ * JSON or version mismatch. */
24
+ export async function readComponentCssManifest(
25
+ absolutePath: string,
26
+ ): Promise<ComponentCssManifest | null> {
27
+ const f = Bun.file(absolutePath)
28
+ if (!(await f.exists())) return null
29
+ const text = await f.text()
30
+ let parsed: unknown
31
+ try {
32
+ parsed = JSON.parse(text)
33
+ } catch (e) {
34
+ throw new Error(
35
+ `component-manifest.json is malformed: ${e instanceof Error ? e.message : String(e)}`,
36
+ )
37
+ }
38
+ if (!parsed || typeof parsed !== 'object' || (parsed as { version?: unknown }).version !== 1) {
39
+ throw new Error('component-manifest.json version mismatch (expected 1)')
40
+ }
41
+ return parsed as ComponentCssManifest
42
+ }
43
+
44
+ /** Write to disk. Creates the parent directory if needed. */
45
+ export async function writeComponentCssManifest(
46
+ absolutePath: string,
47
+ manifest: ComponentCssManifest,
48
+ ): Promise<void> {
49
+ await mkdir(path.dirname(absolutePath), { recursive: true })
50
+ await writeFile(absolutePath, JSON.stringify(manifest, null, 2), 'utf-8')
51
+ }
@@ -0,0 +1,56 @@
1
+ import { transform } from 'lightningcss'
2
+
3
+ export interface ProcessCssOptions {
4
+ /** Absolute path; Lightning CSS uses this to derive [hash] in cssModules pattern. */
5
+ filename: string
6
+ /** Source CSS text. */
7
+ source: string
8
+ /** True iff this file is a .module.css. */
9
+ isModule: boolean
10
+ /** Optional Tailwind compiler. When set, source is piped through it FIRST
11
+ * so @apply directives resolve before module class rewriting. Null when
12
+ * Tailwind isn't available. */
13
+ tailwindCompile: {
14
+ build(candidates: string[]): string
15
+ sources: { base: string; pattern: string; negated: boolean }[]
16
+ } | null
17
+ }
18
+
19
+ export interface ProcessCssResult {
20
+ /** Compiled CSS bytes. */
21
+ code: Uint8Array
22
+ /** null for non-module files; original→hashed name map for .module.css. */
23
+ exports: Record<string, string> | null
24
+ }
25
+
26
+ /** Pipe a CSS file through Tailwind (if available) then Lightning CSS.
27
+ * For .module.css, class names are hashed via Lightning's default
28
+ * pattern `[local]_[hash]` where [hash] is file-derived (~6 chars).
29
+ * Two classes in the same file share the suffix; two files with the same
30
+ * class name get different hashes. */
31
+ export async function processCssFile(opts: ProcessCssOptions): Promise<ProcessCssResult> {
32
+ let css = opts.source
33
+
34
+ if (opts.tailwindCompile) {
35
+ const { Scanner } = await import('@tailwindcss/oxide')
36
+ const scanner = new Scanner({ sources: opts.tailwindCompile.sources })
37
+ const candidates = scanner.scan()
38
+ css = opts.tailwindCompile.build(candidates) + '\n' + css
39
+ }
40
+
41
+ const result = transform({
42
+ filename: opts.filename,
43
+ code: Buffer.from(css, 'utf-8'),
44
+ cssModules: opts.isModule ? { pattern: '[local]_[hash]' } : false,
45
+ })
46
+
47
+ if (!opts.isModule) {
48
+ return { code: result.code, exports: null }
49
+ }
50
+
51
+ const flat: Record<string, string> = {}
52
+ for (const [k, v] of Object.entries(result.exports ?? {})) {
53
+ flat[k] = (v as { name: string }).name
54
+ }
55
+ return { code: result.code, exports: flat }
56
+ }
@@ -0,0 +1,33 @@
1
+ import type { CssDep } from './scan-imports.ts'
2
+
3
+ export interface RouteForCss {
4
+ /** Route.fullPath (e.g. '/' or '/blog/{slug}'). */
5
+ fullPath: string
6
+ /** Absolute path of the route's Component source file. */
7
+ componentSource: string
8
+ }
9
+
10
+ /** Build the route → CSS chunk hrefs map.
11
+ *
12
+ * MVP: direct lookup only. We collect CSS deps from each route's
13
+ * componentSource file (the file that defines the Component used in
14
+ * defineRoutes). Transitive walking into nested components is a future
15
+ * enhancement — for now, users put @import or @apply or co-locate CSS
16
+ * with the route component. */
17
+ export function computeRouteChunks(
18
+ routes: RouteForCss[],
19
+ scan: Map<string, CssDep[]>,
20
+ modules: Record<string, { chunk: string }>,
21
+ ): Record<string, string[]> {
22
+ const out: Record<string, string[]> = {}
23
+ for (const r of routes) {
24
+ const deps = scan.get(r.componentSource) ?? []
25
+ const chunks = new Set<string>()
26
+ for (const d of deps) {
27
+ const mod = modules[d.path]
28
+ if (mod) chunks.add(mod.chunk)
29
+ }
30
+ out[r.fullPath] = Array.from(chunks).sort()
31
+ }
32
+ return out
33
+ }
@@ -0,0 +1,79 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import ts from 'typescript'
4
+
5
+ export interface CssDep {
6
+ /** Absolute path to the .css or .module.css file. */
7
+ path: string
8
+ /** True iff the file ends with `.module.css`. */
9
+ isModule: boolean
10
+ /** Default-import binding name (e.g. `styles` for `import styles from ...`).
11
+ * null for side-effect imports (`import './foo.css'`). */
12
+ importedName: string | null
13
+ }
14
+
15
+ const IGNORE_DIRS = new Set(['node_modules', '.git', '.brust', 'dist'])
16
+ const SOURCE_EXT_RE = /\.(tsx?|jsx?)$/
17
+ const TEST_RE = /\.test\.(tsx?|jsx?)$/
18
+
19
+ /** Walk `scanRoot` recursively, parse every TS/TSX file with the TypeScript
20
+ * compiler API, return a map of source file → CSS deps. Files outside
21
+ * scanRoot, inside ignored dirs, or test files are skipped. */
22
+ export async function scanCssImports(scanRoot: string): Promise<Map<string, CssDep[]>> {
23
+ const out = new Map<string, CssDep[]>()
24
+ await walk(scanRoot, scanRoot, out)
25
+ return out
26
+ }
27
+
28
+ async function walk(root: string, dir: string, out: Map<string, CssDep[]>): Promise<void> {
29
+ let entries: string[]
30
+ try {
31
+ entries = await readdir(dir)
32
+ } catch {
33
+ return
34
+ }
35
+ for (const name of entries) {
36
+ if (IGNORE_DIRS.has(name)) continue
37
+ const full = path.join(dir, name)
38
+ let st: Awaited<ReturnType<typeof stat>>
39
+ try {
40
+ st = await stat(full)
41
+ } catch {
42
+ continue
43
+ }
44
+ if (st.isDirectory()) {
45
+ await walk(root, full, out)
46
+ } else if (SOURCE_EXT_RE.test(name) && !TEST_RE.test(name)) {
47
+ const deps = await depsForFile(full)
48
+ if (deps.length > 0) out.set(full, deps)
49
+ }
50
+ }
51
+ }
52
+
53
+ async function depsForFile(file: string): Promise<CssDep[]> {
54
+ const src = await readFile(file, 'utf-8')
55
+ const sf = ts.createSourceFile(
56
+ file,
57
+ src,
58
+ ts.ScriptTarget.Latest,
59
+ /*setParentNodes*/ true,
60
+ ts.ScriptKind.TSX,
61
+ )
62
+ const deps: CssDep[] = []
63
+ ts.forEachChild(sf, (node) => {
64
+ if (!ts.isImportDeclaration(node)) return
65
+ const spec = node.moduleSpecifier
66
+ if (!ts.isStringLiteral(spec)) return
67
+ const raw = spec.text
68
+ if (!raw.endsWith('.css')) return
69
+ const absPath = path.resolve(path.dirname(file), raw)
70
+ const isModule = raw.endsWith('.module.css')
71
+ let importedName: string | null = null
72
+ if (node.importClause?.name) {
73
+ // default import: `import styles from './x.module.css'`
74
+ importedName = node.importClause.name.getText(sf)
75
+ }
76
+ deps.push({ path: absPath, isModule, importedName })
77
+ })
78
+ return deps
79
+ }
package/runtime/css.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Module-scope state read by the renderer to decide whether to inject
2
+ // <link rel="stylesheet"> tags into the first chunk. Mirrors the
3
+ // consume-flag pattern used by islands — per-worker (workers re-execute
4
+ // the bundle and get their own copy).
5
+ let cssHrefs: string[] = []
6
+
7
+ /** Configure the list of stylesheet hrefs that the renderer should
8
+ * inject into the SSR HTML before </head>. Replaces any previous list.
9
+ * Called from brust.run() main and worker branches (both need to know
10
+ * so the per-worker renderer can inject). */
11
+ export function configureCssEnabled(hrefs: readonly string[]): void {
12
+ cssHrefs = hrefs.slice()
13
+ }
14
+
15
+ /** Returns the configured hrefs as a defensive copy. */
16
+ export function getCssHrefs(): readonly string[] {
17
+ return cssHrefs.slice()
18
+ }
19
+
20
+ const routeHrefs = new Map<string, readonly string[]>()
21
+
22
+ /** Set the CSS hrefs to inject for a specific route. Replaces any previous
23
+ * list for that route. Called from brust.run() main after the component
24
+ * manifest loads. Keys are route.fullPath strings (e.g. '/' or '/blog/{slug}'). */
25
+ export function configureCssHrefsForRoute(routePath: string, hrefs: readonly string[]): void {
26
+ routeHrefs.set(routePath, hrefs.slice())
27
+ }
28
+
29
+ /** Returns the CSS hrefs for a specific route, or [] when none configured.
30
+ * Defensive copy on the way out. */
31
+ export function getCssHrefsForRoute(routePath: string): readonly string[] {
32
+ return (routeHrefs.get(routePath) ?? []).slice()
33
+ }
34
+
35
+ /** @internal — used by the unit test suite to wipe both global and per-route state. */
36
+ export function _resetCssForTests(): void {
37
+ cssHrefs = []
38
+ routeHrefs.clear()
39
+ }
@@ -0,0 +1,49 @@
1
+ /** Browser dev client. Inlined into the SSR first chunk via a <script
2
+ * type="module">…</script> wrapper. Connects WS at /_brust/dev, handles
3
+ * reload / css-update / error / ok messages, manages a red overlay.
4
+ *
5
+ * Keep this string short — it ships in every dev-mode SSR response. */
6
+ export const DEV_CLIENT_JS = String.raw`
7
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
8
+ let ws;
9
+ function connect() {
10
+ ws = new WebSocket(proto + '//' + location.host + '/_brust/dev');
11
+ ws.onmessage = function (e) { handle(JSON.parse(e.data)); };
12
+ ws.onclose = function () { setTimeout(connect, 1000); };
13
+ ws.onerror = function () { /* swallow; onclose triggers reconnect */ };
14
+ }
15
+ function handle(msg) {
16
+ switch (msg.type) {
17
+ case 'reload': location.reload(); break;
18
+ case 'css-update': swapCssLink(msg.href); break;
19
+ case 'error': showOverlay(msg.message, msg.stack); break;
20
+ case 'ok': hideOverlay(); break;
21
+ }
22
+ }
23
+ function swapCssLink(href) {
24
+ const url = new URL(href, location.origin);
25
+ document.querySelectorAll('link[rel="stylesheet"]').forEach(function (link) {
26
+ if (new URL(link.href).pathname === url.pathname) link.href = href;
27
+ });
28
+ }
29
+ function showOverlay(msg, stack) {
30
+ let el = document.getElementById('__brust_dev_overlay');
31
+ if (!el) {
32
+ el = document.createElement('div');
33
+ el.id = '__brust_dev_overlay';
34
+ el.style.cssText = 'position:fixed;inset:0;background:rgba(180,30,30,0.96);color:#fff;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:24px;z-index:2147483647;white-space:pre-wrap;overflow:auto;';
35
+ document.body.appendChild(el);
36
+ }
37
+ el.textContent = '[brust dev] build error\n\n' + msg + (stack ? '\n\n' + stack : '');
38
+ }
39
+ function hideOverlay() {
40
+ const el = document.getElementById('__brust_dev_overlay');
41
+ if (el) el.remove();
42
+ }
43
+ connect();
44
+ `.trim()
45
+
46
+ /** Build the full <script> tag that gets spliced before </head>. */
47
+ export function buildDevClientTag(): string {
48
+ return '<script type="module">' + DEV_CLIENT_JS + '</script>'
49
+ }
@@ -0,0 +1,127 @@
1
+ import type { DevMessage } from './ws-channel.ts'
2
+ import type { ChangeKind } from './watcher.ts'
3
+
4
+ export interface CoordinatorDeps {
5
+ workers: {
6
+ terminateAll(): Promise<void>
7
+ spawnAll(): Promise<void>
8
+ }
9
+ buildCss: () => Promise<void>
10
+ buildIslands: () => Promise<void>
11
+ buildComponentCss?: () => Promise<void>
12
+ snapshotComponentCss?: () => Promise<import('../css/manifest.ts').ComponentCssManifest | null>
13
+ broadcast: (msg: DevMessage) => Promise<void> | void
14
+ tui: { appendEvent(line: string): void }
15
+ }
16
+
17
+ type State = 'idle' | 'building'
18
+
19
+ export class Coordinator {
20
+ private state: State = 'idle'
21
+
22
+ constructor(private deps: CoordinatorDeps) {}
23
+
24
+ async handleChange(ev: { paths: string[]; kind: ChangeKind }): Promise<void> {
25
+ if (this.state === 'building') return
26
+ this.state = 'building'
27
+ const started = performance.now()
28
+ try {
29
+ await this.deps.broadcast({ type: 'building' })
30
+ this.deps.tui.appendEvent(formatStart(ev))
31
+ switch (ev.kind) {
32
+ case 'ts':
33
+ case 'html':
34
+ await this.deps.workers.terminateAll()
35
+ await this.deps.workers.spawnAll()
36
+ await this.deps.broadcast({ type: 'reload' })
37
+ break
38
+ case 'islands':
39
+ await this.deps.buildIslands()
40
+ await this.deps.workers.terminateAll()
41
+ await this.deps.workers.spawnAll()
42
+ await this.deps.broadcast({ type: 'reload' })
43
+ break
44
+ case 'css':
45
+ await this.deps.buildCss()
46
+ await this.deps.broadcast({
47
+ type: 'css-update',
48
+ href: '/_brust/css/app.css?v=' + Date.now(),
49
+ })
50
+ break
51
+ case 'component-css': {
52
+ const before = await this.deps.snapshotComponentCss?.()
53
+ await this.deps.buildComponentCss?.()
54
+ const after = await this.deps.snapshotComponentCss?.()
55
+ if (!exportsEqualForChanged(before ?? null, after ?? null, ev.paths)) {
56
+ await this.deps.broadcast({ type: 'reload' })
57
+ } else {
58
+ const chunks = chunksForPaths(after ?? null, ev.paths)
59
+ for (const c of chunks) {
60
+ await this.deps.broadcast({
61
+ type: 'css-update',
62
+ href: `${c}?v=${Date.now()}`,
63
+ })
64
+ }
65
+ }
66
+ break
67
+ }
68
+ }
69
+ const ms = (performance.now() - started) | 0
70
+ this.deps.tui.appendEvent(` → ok (${ms}ms)`)
71
+ await this.deps.broadcast({ type: 'ok' })
72
+ } catch (e: any) {
73
+ this.deps.tui.appendEvent(` ✗ ${e.message ?? String(e)}`)
74
+ await this.deps.broadcast({
75
+ type: 'error',
76
+ message: e.message ?? String(e),
77
+ stack: e.stack,
78
+ })
79
+ } finally {
80
+ this.state = 'idle'
81
+ }
82
+ }
83
+ }
84
+
85
+ function formatStart(ev: { paths: string[]; kind: ChangeKind }): string {
86
+ const icon = ev.kind === 'css' ? '⎈' : '⏵'
87
+ const label =
88
+ ev.kind === 'css'
89
+ ? 'css update'
90
+ : ev.kind === 'component-css'
91
+ ? 'component css update'
92
+ : ev.kind === 'islands'
93
+ ? 'islands rebuild'
94
+ : 'hotreload'
95
+ return `${icon} ${label} ${ev.paths[0]}`
96
+ }
97
+
98
+ function exportsEqualForChanged(
99
+ before: import('../css/manifest.ts').ComponentCssManifest | null,
100
+ after: import('../css/manifest.ts').ComponentCssManifest | null,
101
+ paths: string[],
102
+ ): boolean {
103
+ if (!before || !after) return false
104
+ for (const p of paths) {
105
+ const b = before.modules[p]?.exports ?? null
106
+ const a = after.modules[p]?.exports ?? null
107
+ if (b === null && a === null) continue
108
+ if (b === null || a === null) return false
109
+ const bk = Object.keys(b).sort().join(',')
110
+ const ak = Object.keys(a).sort().join(',')
111
+ if (bk !== ak) return false
112
+ }
113
+ return true
114
+ }
115
+
116
+ function chunksForPaths(
117
+ manifest: import('../css/manifest.ts').ComponentCssManifest | null,
118
+ paths: string[],
119
+ ): string[] {
120
+ if (!manifest) return []
121
+ const out = new Set<string>()
122
+ for (const p of paths) {
123
+ const c = manifest.modules[p]?.chunk
124
+ if (c) out.add(c)
125
+ }
126
+ return Array.from(out)
127
+ }
@@ -0,0 +1,17 @@
1
+ // Module-scope state for the dev-client <script> snippet that the renderer
2
+ // splices into the SSR first chunk in dev mode. Mirrors runtime/css.ts.
3
+ // Workers re-execute the bundle and get their own copy; brust.run() sets
4
+ // the snippet on both main and worker startup paths.
5
+ let snippet: string | null = null
6
+
7
+ /** Set the dev-client snippet (full `<script>…</script>` tag) the renderer
8
+ * should inject before `</head>`. Pass `null` to disable injection (the
9
+ * default in non-dev mode). */
10
+ export function configureDevClientSnippet(s: string | null): void {
11
+ snippet = s
12
+ }
13
+
14
+ /** Returns the configured snippet, or `null` when dev mode is off. */
15
+ export function getDevClientSnippet(): string | null {
16
+ return snippet
17
+ }
@@ -0,0 +1,109 @@
1
+ const ESC = '\x1b['
2
+ const HIDE_CURSOR = ESC + '?25l'
3
+ const SHOW_CURSOR = ESC + '?25h'
4
+ const CLEAR_SCREEN = ESC + '2J' + ESC + 'H'
5
+ const RESET = ESC + '0m'
6
+ const DIM = ESC + '2m'
7
+ const BRAND = ESC + '38;2;138;51;36m'
8
+ const GREEN = ESC + '32m'
9
+ const RED = ESC + '31m'
10
+ const YELLOW = ESC + '33m'
11
+
12
+ const DEFAULT_CAPACITY = 10
13
+
14
+ interface Status {
15
+ port: number
16
+ workers: number
17
+ watching: string[]
18
+ }
19
+
20
+ export interface TuiOptions {
21
+ isTty: boolean
22
+ write: (s: string) => void
23
+ capacity?: number
24
+ }
25
+
26
+ export class Tui {
27
+ private events: string[] = []
28
+ private status: Status | null = null
29
+ private capacity: number
30
+ private state: 'idle' | 'building' | 'failed' = 'idle'
31
+
32
+ constructor(private opts: TuiOptions) {
33
+ this.capacity = opts.capacity ?? DEFAULT_CAPACITY
34
+ }
35
+
36
+ eventsForTests(): string[] {
37
+ return [...this.events]
38
+ }
39
+
40
+ updateStatus(s: Status): void {
41
+ this.status = s
42
+ this.render()
43
+ }
44
+
45
+ setState(s: 'idle' | 'building' | 'failed'): void {
46
+ this.state = s
47
+ this.render()
48
+ }
49
+
50
+ appendEvent(line: string): void {
51
+ this.events.push(line)
52
+ if (this.events.length > this.capacity) this.events.shift()
53
+ this.render()
54
+ }
55
+
56
+ stop(): void {
57
+ if (this.opts.isTty) this.opts.write(SHOW_CURSOR + RESET)
58
+ }
59
+
60
+ private render(): void {
61
+ if (!this.opts.isTty) {
62
+ const latest = this.events[this.events.length - 1]
63
+ if (latest) this.opts.write(latest + '\n')
64
+ return
65
+ }
66
+ let out = HIDE_CURSOR + CLEAR_SCREEN
67
+ out += this.renderHeader()
68
+ out += this.renderEvents()
69
+ out += this.renderStatusBar()
70
+ this.opts.write(out)
71
+ }
72
+
73
+ private renderHeader(): string {
74
+ if (!this.status) return BRAND + 'brust 0.1.0 · dev mode' + RESET + '\n\n'
75
+ return (
76
+ BRAND +
77
+ 'brust 0.1.0 · dev mode' +
78
+ RESET +
79
+ '\n' +
80
+ DIM +
81
+ 'port: ' +
82
+ RESET +
83
+ this.status.port +
84
+ '\n' +
85
+ DIM +
86
+ 'workers: ' +
87
+ RESET +
88
+ this.status.workers +
89
+ '\n' +
90
+ DIM +
91
+ 'watching: ' +
92
+ RESET +
93
+ this.status.watching.join(', ') +
94
+ '\n\n'
95
+ )
96
+ }
97
+
98
+ private renderEvents(): string {
99
+ let out = ''
100
+ for (const ev of this.events) out += ev + '\n'
101
+ return out + '\n'
102
+ }
103
+
104
+ private renderStatusBar(): string {
105
+ if (this.state === 'building') return YELLOW + '◉ Building…' + RESET + '\n'
106
+ if (this.state === 'failed') return RED + '✗ Build failed' + RESET + '\n'
107
+ return GREEN + '◉ Serving' + RESET + '\n'
108
+ }
109
+ }