brustjs 0.1.64-alpha → 0.1.66-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.
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
14
  import path from 'node:path'
15
- import { insertGeneratorMeta, resolveGenerator } from '../generator.ts'
15
+ import { injectAiScriptIntoTemplate, insertGeneratorMeta, resolveGenerator } from '../generator.ts'
16
16
  import {
17
17
  bakeDirectivesIfUsed,
18
18
  buildChainWrapperSource,
@@ -66,6 +66,9 @@ export interface MdEmitOpts {
66
66
  * BRUST_DEV injection — md pages render Rust-side and never pass through the
67
67
  * React renderer's dev-client injection). */
68
68
  withDevClient?: boolean
69
+ /** Bake the AI runtime tag into document-style md templates. Fragments skip
70
+ * the injection entirely. */
71
+ aiEnabled?: boolean
69
72
  /** What to do when a route's md file no longer exists on disk (deleted after
70
73
  * the route table was built). emitMdTemplates serves BOTH `brust build` and
71
74
  * the dev re-emit, and the two must diverge here:
@@ -400,6 +403,7 @@ export async function emitMdTemplates(opts: MdEmitOpts): Promise<{
400
403
  }
401
404
  final = bakeDirectivesIfUsed(final, hasDirectives)
402
405
  if (opts.withDevClient) final = injectDevClientIntoTemplate(final)
406
+ if (!opts.withDevClient && opts.aiEnabled) final = injectAiScriptIntoTemplate(final)
403
407
  writeFileSync(outPath, final)
404
408
  }
405
409
 
@@ -0,0 +1,38 @@
1
+ const ENC = new TextEncoder()
2
+
3
+ /** Splice `snippet` into `body` immediately before the first `</head>`
4
+ * (case-insensitive on the four ASCII letters only). Returns the original body
5
+ * untouched if `snippet` is null/empty or if `</head>` is absent.
6
+ *
7
+ * AI pages are document-only: fragment templates skip this injection entirely. */
8
+ export function injectAiClient(body: Uint8Array, snippet: string | null): Uint8Array {
9
+ if (!snippet) return body
10
+ const pos = findHeadCloseTag(body)
11
+ if (pos < 0) return body
12
+ const tagBytes = ENC.encode(snippet)
13
+ const out = new Uint8Array(body.length + tagBytes.length)
14
+ out.set(body.subarray(0, pos), 0)
15
+ out.set(tagBytes, pos)
16
+ out.set(body.subarray(pos), pos + tagBytes.length)
17
+ return out
18
+ }
19
+
20
+ function findHeadCloseTag(body: Uint8Array): number {
21
+ const LT = 0x3c,
22
+ SL = 0x2f,
23
+ GT = 0x3e
24
+ for (let i = 0, max = body.length - 6; i < max; i++) {
25
+ if (body[i] !== LT || body[i + 1] !== SL) continue
26
+ if (!isLetter(body[i + 2], 0x48)) continue
27
+ if (!isLetter(body[i + 3], 0x45)) continue
28
+ if (!isLetter(body[i + 4], 0x41)) continue
29
+ if (!isLetter(body[i + 5], 0x44)) continue
30
+ if (body[i + 6] !== GT) continue
31
+ return i
32
+ }
33
+ return -1
34
+ }
35
+
36
+ function isLetter(b: number, u: number): boolean {
37
+ return b === u || b === (u | 0x20)
38
+ }
@@ -7,6 +7,7 @@ import { Writable } from 'node:stream'
7
7
  import { IslandUsedContext, createIslandUsedBox } from '../islands/island.tsx'
8
8
  import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
9
9
  import { injectCssLink } from './inject-css-link.ts'
10
+ import { injectAiClient } from './inject-ai-client.ts'
10
11
  import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
11
12
  import { injectDevClient } from './inject-dev-client.ts'
12
13
  import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-prefix.ts'
@@ -14,6 +15,7 @@ import { injectBrustStore, buildStoreScripts } from './inject-store.ts'
14
15
  import { getDevClientSnippet } from '../dev/inject.ts'
15
16
  import { getGeneratorMeta, injectGeneratorMeta } from './inject-generator.ts'
16
17
  import { injectShellMeta, shellMetaTag } from './inject-shell-meta.ts'
18
+ import { aiScriptTag } from '../generator.ts'
17
19
 
18
20
  export interface RenderBranchStreamingArgs {
19
21
  element: ReactNode
@@ -64,6 +66,7 @@ export interface RenderBranchStreamingArgs {
64
66
  }
65
67
 
66
68
  const encoder = new TextEncoder()
69
+ const decoder = new TextDecoder()
67
70
 
68
71
  /** JSON.stringify the per-chunk meta. Defaults match the renderToString
69
72
  * path so single-chunk responses keep their existing wire shape. */
@@ -120,6 +123,18 @@ function concatBuffers(parts: Uint8Array[], withBootstrap: boolean): Uint8Array
120
123
  return out
121
124
  }
122
125
 
126
+ function injectOrPrependDevClient(body: Uint8Array, snippet: string | null): Uint8Array {
127
+ if (!snippet) return body
128
+ if (decoder.decode(body).toLowerCase().includes('</head>')) {
129
+ return injectDevClient(body, snippet)
130
+ }
131
+ const snippetBytes = encoder.encode(snippet)
132
+ const out = new Uint8Array(snippetBytes.length + body.length)
133
+ out.set(snippetBytes, 0)
134
+ out.set(body, snippetBytes.length)
135
+ return out
136
+ }
137
+
123
138
  export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<void> {
124
139
  const { element, view, workerId, napi, errorBoundary } = args
125
140
  const slot = args.slot ?? 0
@@ -184,6 +199,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
184
199
  body = injectGeneratorMeta(body, getGeneratorMeta())
185
200
  body = injectShellMeta(body, args.shellId ?? '')
186
201
  body = injectDevClient(body, getDevClientSnippet())
202
+ body = injectAiClient(body, process.env.BRUST_AI === '1' ? aiScriptTag() : null)
187
203
  body = injectActionPrefix(body, getActionPrefixSnippet())
188
204
  body = injectBrustStore(body, args.storeSnapshot ?? null)
189
205
  const meta = makeMeta({
@@ -246,6 +262,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
246
262
  .map((h) => `<link rel="stylesheet" href="${h}">`)
247
263
  .join('')
248
264
  const devTag = getDevClientSnippet() ?? ''
265
+ const aiTag = process.env.BRUST_AI === '1' ? aiScriptTag() : ''
249
266
  const prefixTag = getActionPrefixSnippet() ?? ''
250
267
  const storeTag = buildStoreScripts(args.storeSnapshot ?? null)
251
268
  const genTag = getGeneratorMeta() ?? ''
@@ -253,6 +270,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
253
270
  if (
254
271
  linkTagsStr.length > 0 ||
255
272
  devTag.length > 0 ||
273
+ aiTag.length > 0 ||
256
274
  prefixTag.length > 0 ||
257
275
  storeTag.length > 0 ||
258
276
  genTag.length > 0 ||
@@ -261,9 +279,10 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
261
279
  const prepend = encoder.encode(
262
280
  genTag + shellTag + linkTagsStr + prefixTag + devTag + storeTag,
263
281
  )
264
- const out = new Uint8Array(flushed.length + prepend.length)
265
- out.set(flushed, 0)
266
- out.set(prepend, flushed.length)
282
+ const out = new Uint8Array(prepend.length + aiTag.length + flushed.length)
283
+ out.set(prepend, 0)
284
+ out.set(encoder.encode(aiTag), prepend.length)
285
+ out.set(flushed, prepend.length + aiTag.length)
267
286
  flushed = out
268
287
  }
269
288
  const meta = makeMeta({ status: successStatus, streaming: true, headers: extraHeaders })
@@ -298,11 +317,12 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
298
317
  onShellError(err) {
299
318
  try {
300
319
  const html = renderToString(createElement(errorBoundary, { error: err as Error }))
320
+ const body = injectOrPrependDevClient(encoder.encode(html), getDevClientSnippet())
301
321
  const meta = makeMeta({ status: 500, streaming: false })
302
322
  mode = 'done'
303
323
  ;(async () => {
304
324
  try {
305
- const len = encodeFirstChunk(view, meta, encoder.encode(html))
325
+ const len = encodeFirstChunk(view, meta, body)
306
326
  await napi.renderChunkFinal(workerId, slot, len, view)
307
327
  finalSent = true
308
328
  resolve()
@@ -312,6 +332,23 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
312
332
  })()
313
333
  } catch (e2) {
314
334
  console.error('[brust] errorBoundary threw during shell error:', e2)
335
+ const devSnippet = getDevClientSnippet()
336
+ if (devSnippet) {
337
+ const meta = makeMeta({ status: 500, streaming: false })
338
+ const html = `<!doctype html><html><head>${devSnippet}</head><body>Internal Server Error</body></html>`
339
+ mode = 'done'
340
+ ;(async () => {
341
+ try {
342
+ const len = encodeFirstChunk(view, meta, encoder.encode(html))
343
+ await napi.renderChunkFinal(workerId, slot, len, view)
344
+ finalSent = true
345
+ resolve()
346
+ } catch (e) {
347
+ reject(e)
348
+ }
349
+ })()
350
+ return
351
+ }
315
352
  const meta = makeMeta({
316
353
  status: 500,
317
354
  streaming: false,
@@ -0,0 +1,17 @@
1
+ import type { FlatRoute } from '../routes.ts';
2
+ export interface AiPageEntry {
3
+ path: string;
4
+ params: string[];
5
+ catchAll: boolean;
6
+ kind: 'react' | 'native' | 'md';
7
+ shellId: string;
8
+ title?: string;
9
+ description?: string;
10
+ }
11
+ export interface AiManifest {
12
+ version: 1;
13
+ pages: AiPageEntry[];
14
+ }
15
+ export declare function extractAiManifest(routes: FlatRoute[]): AiManifest;
16
+ export declare function writeManifest(cwd: string, manifest: AiManifest): Promise<void>;
17
+ export declare function readManifest(cwd: string): Promise<AiManifest | null>;
@@ -71,7 +71,11 @@ export declare function countMainTags(template: string): number;
71
71
  *
72
72
  * Exported for the md emit step (runtime/md/emit.ts), which bakes the same tag
73
73
  * under its `withDevClient` option — md pages render Rust-side too, so without
74
- * it they never auto-reload in dev. */
74
+ * it they never auto-reload in dev.
75
+ *
76
+ * The AI runtime script is injected here as well when BRUST_AI=1. The tag is
77
+ * document-only: the compiler emits a head anchor for full documents, while
78
+ * fragment templates (no head) are left unchanged. */
75
79
  export declare function injectDevClientIntoTemplate(template: string): string;
76
80
  /** Bake the directive runtime loader into a native template iff it uses any
77
81
  * x-data directive. Idempotent. Wrapped in {% raw %} for symmetry with the islands
package/types/config.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface BrustConfig {
10
10
  cacheMaxEntries?: number;
11
11
  /** L2 page-cache capacity (entries). Undefined → Rust default of 1000. */
12
12
  cachePageMaxEntries?: number;
13
+ /** AI runtime toggle from BRUST_AI. Dev mode enables it separately. */
14
+ ai?: boolean;
13
15
  /** R9 cross-process cache invalidation: redis/dragonfly URL. Absent →
14
16
  * feature disabled (current single-process behavior). */
15
17
  cacheSyncUrl?: string;
@@ -7,6 +7,8 @@ export interface CoordinatorDeps {
7
7
  };
8
8
  buildCss: () => Promise<void>;
9
9
  buildIslands: () => Promise<void>;
10
+ /** Parse changed JS/TS modules before mutating the live generation. */
11
+ validateChanges: (paths: string[]) => Promise<void>;
10
12
  /** Recompile native-route `.jinja` templates from source and reload them into
11
13
  * the minijinja env, so `native: true` routes pick up .tsx edits on reload. */
12
14
  reEmitJinja: () => Promise<void>;
@@ -24,10 +26,15 @@ export interface CoordinatorDeps {
24
26
  }
25
27
  export declare class Coordinator {
26
28
  private deps;
27
- private state;
29
+ private readonly pending;
30
+ private drainPromise;
28
31
  constructor(deps: CoordinatorDeps);
29
32
  handleChange(ev: {
30
33
  paths: string[];
31
34
  kind: ChangeKind;
32
35
  }): Promise<void>;
36
+ private startDrain;
37
+ private drainPending;
38
+ private takeNext;
39
+ private runBatch;
33
40
  }
@@ -0,0 +1 @@
1
+ export declare function validateChangedModules(paths: string[]): Promise<void>;
@@ -26,9 +26,10 @@ export interface CreateWatcherOptions {
26
26
  export interface Watcher {
27
27
  close(): void;
28
28
  }
29
- /** Watch `root` recursively. Emits one `onChange` call per debounce window
30
- * with paths classified by the dominant kind. Mixed-kind windows pick
31
- * by priority: islands > ts > html > css (islands trigger a full restart
32
- * that subsumes the others). */
29
+ /** Internal exposed for deterministic callback-contract tests. */
30
+ export declare function _testDispatchChanges(paths: string[], opts: Pick<CreateWatcherOptions, 'root' | 'hasMdRoutes' | 'onChange'>): void;
31
+ /** Watch `root` recursively. Emits one `onChange` call for every distinct kind
32
+ * retained in a debounce window. Priority orders delivery; it never discards
33
+ * lower-priority kinds from a mixed window. */
33
34
  export declare function createWatcher(opts: CreateWatcherOptions): Watcher;
34
35
  export {};
@@ -4,6 +4,11 @@ export interface GeneratorStrings {
4
4
  /** X-Powered-By value, e.g. `brust/0.1.48-alpha` */
5
5
  header: string;
6
6
  }
7
+ /** Full browser entry tag for the AI runtime chunk. */
8
+ export declare function aiScriptTag(): string;
9
+ /** Insert the AI runtime tag immediately before the first `</head>`.
10
+ * Document-only: fragment templates with no head are left unchanged. */
11
+ export declare function injectAiScriptIntoTemplate(template: string): string;
7
12
  /** Build the resolved strings. Version comes from the brustjs package.json
8
13
  * (readVersion never throws — "unknown" degrades to name-only, never a crash).
9
14
  * The version is sanitized to attr/header-safe bytes; semver chars only. */
package/types/index.d.ts CHANGED
@@ -183,6 +183,8 @@ export declare const brust: {
183
183
  /** Optional global CORS policy — see {@link CorsOptions}. Threaded to
184
184
  * serve() like `actionPrefix`. */
185
185
  cors?: CorsOptions;
186
+ /** AI runtime toggle. Dev mode enables it automatically. */
187
+ ai?: boolean;
186
188
  /** Overrides merged into the underlying `serve()` call (main thread). */
187
189
  serve?: Partial<Omit<ServeOptions, "entry" | "actions" | "mcp">>;
188
190
  /** Per-worker SAB size in bytes. Default 256 KB. */
@@ -18,6 +18,14 @@ export interface BuildIslandsOptions {
18
18
  * on the output filename (X.module.css + X.tsx → both X.js). */
19
19
  plugins?: BunPlugin[];
20
20
  }
21
+ export interface BuildAiRuntimeOptions {
22
+ /** Override the output directory. Default: `<cwd>/.brust/islands`. */
23
+ outDir?: string;
24
+ /** Override the browser entry file. Default: `runtime/ai/index.ts`. */
25
+ entryFile?: string;
26
+ /** Build plugins passed straight to `Bun.build` for the AI runtime chunk. */
27
+ plugins?: BunPlugin[];
28
+ }
21
29
  /** Scan a routes entry file for `<Island component={X} />` usage and derive the
22
30
  * island chunk list (componentName → absolute source path). Replaces the old
23
31
  * static config-file lookup — the chunk set is derived from source.
@@ -46,4 +54,5 @@ export declare function scanIslandChunks(routesEntryFile: string, extraIslands?:
46
54
  /** Build the runtime chunks + all island chunks + bootstrap. Returns the
47
55
  * absolute output directory; caller passes it to `brust.configureIslandsDir`. */
48
56
  export declare function buildIslands(islands: Map<string, string>, options?: BuildIslandsOptions): Promise<IslandsBuildResult>;
57
+ export declare function buildAiRuntime(options?: BuildAiRuntimeOptions): Promise<string | null>;
49
58
  export declare function buildOne(entrypoints: string[], outdir: string, naming: string, external: string[], plugins?: BunPlugin[]): Promise<void>;
@@ -27,6 +27,9 @@ export interface MdEmitOpts {
27
27
  * BRUST_DEV injection — md pages render Rust-side and never pass through the
28
28
  * React renderer's dev-client injection). */
29
29
  withDevClient?: boolean;
30
+ /** Bake the AI runtime tag into document-style md templates. Fragments skip
31
+ * the injection entirely. */
32
+ aiEnabled?: boolean;
30
33
  /** What to do when a route's md file no longer exists on disk (deleted after
31
34
  * the route table was built). emitMdTemplates serves BOTH `brust build` and
32
35
  * the dev re-emit, and the two must diverge here:
@@ -0,0 +1,6 @@
1
+ /** Splice `snippet` into `body` immediately before the first `</head>`
2
+ * (case-insensitive on the four ASCII letters only). Returns the original body
3
+ * untouched if `snippet` is null/empty or if `</head>` is absent.
4
+ *
5
+ * AI pages are document-only: fragment templates skip this injection entirely. */
6
+ export declare function injectAiClient(body: Uint8Array, snippet: string | null): Uint8Array;