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,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
|
+
}
|