brustjs 0.1.50-alpha → 0.1.52-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 (90) hide show
  1. package/package.json +39 -15
  2. package/runtime/cache-sync.ts +291 -0
  3. package/runtime/cache.ts +4 -0
  4. package/runtime/cli/dev.ts +7 -0
  5. package/runtime/cli/native-routes-emit.ts +147 -1
  6. package/runtime/config.ts +42 -0
  7. package/runtime/index.d.ts +63 -0
  8. package/runtime/index.js +57 -52
  9. package/runtime/index.ts +108 -9
  10. package/runtime/native/runtime.ts +220 -7
  11. package/runtime/render/fragment.ts +87 -0
  12. package/runtime/routes.ts +225 -48
  13. package/runtime/templates.ts +47 -0
  14. package/runtime/treaty.ts +24 -1
  15. package/types/action-error.d.ts +18 -0
  16. package/types/cache-sync.d.ts +42 -0
  17. package/types/cache.d.ts +20 -0
  18. package/types/cli/help.d.ts +28 -0
  19. package/types/cli/jinja-staleness.d.ts +14 -0
  20. package/types/cli/native-routes-emit.d.ts +217 -0
  21. package/types/cli/new.d.ts +30 -0
  22. package/types/cli/templates.d.ts +39 -0
  23. package/types/client/index.d.ts +14 -0
  24. package/types/config.d.ts +42 -0
  25. package/types/cookies.d.ts +25 -0
  26. package/types/create.d.ts +1 -0
  27. package/types/css/build.d.ts +11 -0
  28. package/types/css/component-build.d.ts +17 -0
  29. package/types/css/component-loader.d.ts +8 -0
  30. package/types/css/manifest.d.ts +21 -0
  31. package/types/css/process-modules.d.ts +31 -0
  32. package/types/css/route-deps.d.ts +20 -0
  33. package/types/css/scan-imports.d.ts +13 -0
  34. package/types/css.d.ts +16 -0
  35. package/types/define-actions.d.ts +133 -0
  36. package/types/dev/client.d.ts +8 -0
  37. package/types/dev/coordinator.d.ts +33 -0
  38. package/types/dev/inject.d.ts +6 -0
  39. package/types/dev/jinja-reload.d.ts +7 -0
  40. package/types/dev/tui.d.ts +35 -0
  41. package/types/dev/watcher.d.ts +34 -0
  42. package/types/dev/worker-registry.d.ts +17 -0
  43. package/types/dev/ws-channel.d.ts +39 -0
  44. package/types/generator.d.ts +23 -0
  45. package/types/index.d.ts +222 -0
  46. package/types/islands/brust-page.d.ts +74 -0
  47. package/types/islands/build.d.ts +49 -0
  48. package/types/islands/chunk-id.d.ts +10 -0
  49. package/types/islands/importmap.d.ts +2 -0
  50. package/types/islands/island.d.ts +65 -0
  51. package/types/islands/isr-jsx.d.ts +31 -0
  52. package/types/islands/native-render.d.ts +89 -0
  53. package/types/loader-cache.d.ts +18 -0
  54. package/types/mcp/extractor.d.ts +14 -0
  55. package/types/mcp/manifest.d.ts +23 -0
  56. package/types/mcp/schema.d.ts +19 -0
  57. package/types/mcp/server.d.ts +15 -0
  58. package/types/md/emit.d.ts +72 -0
  59. package/types/md/render.d.ts +80 -0
  60. package/types/md/routes.d.ts +119 -0
  61. package/types/md/scan.d.ts +34 -0
  62. package/types/md/slug.d.ts +1 -0
  63. package/types/native/build.d.ts +30 -0
  64. package/types/native/index.d.ts +2 -0
  65. package/types/native/runtime.d.ts +52 -0
  66. package/types/navigation/active-nav.d.ts +2 -0
  67. package/types/navigation/index.d.ts +5 -0
  68. package/types/navigation/navigate.d.ts +14 -0
  69. package/types/navigation/react.d.ts +15 -0
  70. package/types/navigation/store.d.ts +44 -0
  71. package/types/render/fragment.d.ts +20 -0
  72. package/types/render/inject-action-prefix.d.ts +9 -0
  73. package/types/render/inject-css-link.d.ts +8 -0
  74. package/types/render/inject-dev-client.d.ts +6 -0
  75. package/types/render/inject-generator.d.ts +7 -0
  76. package/types/render/inject-store.d.ts +9 -0
  77. package/types/render/stream.d.ts +45 -0
  78. package/types/request-context.d.ts +16 -0
  79. package/types/routes.d.ts +506 -0
  80. package/types/sse/handler.d.ts +22 -0
  81. package/types/standard-schema.d.ts +31 -0
  82. package/types/store/define-store.d.ts +31 -0
  83. package/types/store/index.d.ts +5 -0
  84. package/types/store/react.d.ts +2 -0
  85. package/types/store/serialize.d.ts +5 -0
  86. package/types/store/server-context.d.ts +4 -0
  87. package/types/store/signal.d.ts +18 -0
  88. package/types/templates.d.ts +18 -0
  89. package/types/treaty.d.ts +70 -0
  90. package/types/ws/handler.d.ts +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.50-alpha",
3
+ "version": "0.1.52-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,12 +41,12 @@
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "optionalDependencies": {
44
- "brustjs-darwin-x64": "0.1.50-alpha",
45
- "brustjs-darwin-arm64": "0.1.50-alpha",
46
- "brustjs-linux-x64-gnu": "0.1.50-alpha",
47
- "brustjs-linux-arm64-gnu": "0.1.50-alpha",
48
- "brustjs-linux-x64-musl": "0.1.50-alpha",
49
- "brustjs-linux-arm64-musl": "0.1.50-alpha"
44
+ "brustjs-darwin-x64": "0.1.52-alpha",
45
+ "brustjs-darwin-arm64": "0.1.52-alpha",
46
+ "brustjs-linux-x64-gnu": "0.1.52-alpha",
47
+ "brustjs-linux-arm64-gnu": "0.1.52-alpha",
48
+ "brustjs-linux-x64-musl": "0.1.52-alpha",
49
+ "brustjs-linux-arm64-musl": "0.1.52-alpha"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^19.2.6",
@@ -71,18 +71,40 @@
71
71
  "zod": "^4.4.3"
72
72
  },
73
73
  "type": "module",
74
- "types": "./runtime/index.d.ts",
74
+ "types": "./types/index.d.ts",
75
75
  "exports": {
76
- ".": "./runtime/index.ts",
77
- "./routes": "./runtime/routes.ts",
78
- "./client": "./runtime/client/index.ts",
79
- "./create": "./runtime/create.ts",
80
- "./store": "./runtime/store/index.ts",
81
- "./native": "./runtime/native/index.ts",
82
- "./navigation": "./runtime/navigation/index.ts"
76
+ ".": {
77
+ "types": "./types/index.d.ts",
78
+ "default": "./runtime/index.ts"
79
+ },
80
+ "./routes": {
81
+ "types": "./types/routes.d.ts",
82
+ "default": "./runtime/routes.ts"
83
+ },
84
+ "./client": {
85
+ "types": "./types/client/index.d.ts",
86
+ "default": "./runtime/client/index.ts"
87
+ },
88
+ "./create": {
89
+ "types": "./types/create.d.ts",
90
+ "default": "./runtime/create.ts"
91
+ },
92
+ "./store": {
93
+ "types": "./types/store/index.d.ts",
94
+ "default": "./runtime/store/index.ts"
95
+ },
96
+ "./native": {
97
+ "types": "./types/native/index.d.ts",
98
+ "default": "./runtime/native/index.ts"
99
+ },
100
+ "./navigation": {
101
+ "types": "./types/navigation/index.d.ts",
102
+ "default": "./runtime/navigation/index.ts"
103
+ }
83
104
  },
84
105
  "files": [
85
106
  "runtime",
107
+ "types",
86
108
  "!runtime/node_modules",
87
109
  "!runtime/package.json",
88
110
  "!runtime/*.node",
@@ -100,6 +122,8 @@
100
122
  "scripts": {
101
123
  "build": "cd runtime && bun run build",
102
124
  "build:debug": "cd runtime && bun run build:debug",
125
+ "build:dts": "tsc -p tsconfig.dts.json && bun scripts/assert-dts.ts",
126
+ "prepack": "bun run build:dts",
103
127
  "test": "bun test tests/integration.test.ts",
104
128
  "dev": "bun runtime/cli/index.ts dev example/pokedex/index.ts",
105
129
  "docs:dev": "cd example/docs && bun ../../runtime/cli/index.ts dev index.ts",
@@ -0,0 +1,291 @@
1
+ // R9 cross-process cache invalidation. Publishes cache.invalidate() calls to a
2
+ // redis/dragonfly pub/sub channel and applies messages from peers to the local
3
+ // process-global Rust caches. Pure TS — Bun's native RedisClient, zero deps.
4
+ //
5
+ // Bun 1.4.0 RedisClient facts (verified empirically — do not "simplify"):
6
+ // - subscribe(channel, cb): cb receives (message, channel). Errors do NOT come
7
+ // through the callback; disconnects fire client.onclose.
8
+ // - Against an unreachable host, connect()/subscribe() return promises that
9
+ // NEVER settle and onclose never fires → every connect must be raced
10
+ // against a timeout or a down redis silently disables the feature.
11
+ // - No auto-resubscribe after a drop → onclose re-creates the client.
12
+ import { RedisClient } from 'bun'
13
+ import type { InvalidateArgs } from './cache.ts'
14
+ import * as native from './index.js'
15
+
16
+ /** Public wire contract (documented for external publishers like studio):
17
+ * `{ "v": 1, "sender": "<uuid>", "key": "...", "tags": ["..."],
18
+ * "path": "...", "method": "GET" }` — all invalidation fields optional,
19
+ * `sender` may be omitted (never matches our token, always applied). */
20
+ export interface CacheSyncMessage {
21
+ v: 1
22
+ sender?: string
23
+ key?: string
24
+ tags?: string[]
25
+ path?: string
26
+ method?: string
27
+ }
28
+
29
+ export const CHANNEL_DEFAULT = 'brust:cache:invalidate'
30
+ const CONNECT_TIMEOUT_MS = 5_000
31
+ const BACKOFF_BASE_MS = 1_000
32
+ const BACKOFF_MAX_MS = 30_000
33
+ const WARN_THROTTLE_MS = 30_000
34
+
35
+ // Per-isolate state. The subscriber lives in the main isolate only (run()
36
+ // wires it); the publisher is lazy per isolate (workers publish from
37
+ // loaders/actions). Rust caches are process-global, so applying here reaches
38
+ // every worker implicitly.
39
+ let started = false
40
+ let stopped = false
41
+ let subscriber: RedisClient | null = null
42
+ let publisher: RedisClient | null = null
43
+ let publisherUrl: string | null = null
44
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
45
+ let backoffMs = BACKOFF_BASE_MS
46
+ const lastWarnAt = new Map<string, number>()
47
+
48
+ /** Injectable publish seam so unit tests never construct a RedisClient. */
49
+ export interface CacheSyncTransport {
50
+ publish(channel: string, message: string): Promise<unknown>
51
+ }
52
+ let testTransport: CacheSyncTransport | null = null
53
+ export function __setTransportForTest(t: CacheSyncTransport | null): void {
54
+ testTransport = t
55
+ }
56
+
57
+ /** The redis URL may carry credentials (`redis://:pass@host:port/db`). Every
58
+ * log line that mentions the target MUST go through this — host:port only. */
59
+ export function redactUrl(url: string): string {
60
+ try {
61
+ const u = new URL(url)
62
+ return `${u.hostname}:${u.port || '6379'}`
63
+ } catch {
64
+ return '<unparseable redis url>'
65
+ }
66
+ }
67
+
68
+ /** Error messages from RedisClient may embed the raw connection string —
69
+ * scrub the configured URL (and any redis://user:pass@ userinfo) before a
70
+ * message reaches the log. */
71
+ function scrubErrorMessage(e: unknown, url: string): string {
72
+ const raw = String((e as Error)?.message ?? e)
73
+ return raw
74
+ .replaceAll(url, redactUrl(url))
75
+ .replace(/rediss?:\/\/[^@\s]+@/gi, 'redis://<redacted>@')
76
+ }
77
+
78
+ /** Once per 30s per error kind — a down redis must not spam the log on every
79
+ * invalidate/retry. */
80
+ function warnThrottled(kind: string, msg: string): void {
81
+ const now = Date.now()
82
+ const last = lastWarnAt.get(kind) ?? 0
83
+ if (now - last < WARN_THROTTLE_MS) return
84
+ lastWarnAt.set(kind, now)
85
+ console.warn(`[brust] ${msg}`)
86
+ }
87
+
88
+ /** Apply a parsed peer message to the local NAPI caches. Direct fan-out —
89
+ * NEVER via cache.invalidate, so a received message can never re-publish
90
+ * (no loop, even across misconfigured channels). Exported for tests. */
91
+ export function applyCacheSyncMessage(msg: CacheSyncMessage): void {
92
+ if (msg === null || typeof msg !== 'object' || msg.v !== 1) {
93
+ warnThrottled('apply', 'cache-sync: dropping message with unsupported shape/version')
94
+ return
95
+ }
96
+ if (
97
+ msg.tags !== undefined &&
98
+ (!Array.isArray(msg.tags) || msg.tags.some((t) => typeof t !== 'string'))
99
+ ) {
100
+ warnThrottled('apply', 'cache-sync: dropping message — tags must be an array of strings')
101
+ return
102
+ }
103
+ for (const field of ['sender', 'key', 'path', 'method'] as const) {
104
+ if (msg[field] !== undefined && typeof msg[field] !== 'string') {
105
+ warnThrottled('apply', `cache-sync: dropping message — ${field} must be a string`)
106
+ return
107
+ }
108
+ }
109
+ // The publishing isolate already applied locally — skip our own messages.
110
+ if (msg.sender && msg.sender === process.env.BRUST_CACHE_SYNC_SENDER) {
111
+ return
112
+ }
113
+ // EXACT mirror of cache.invalidate's local NAPI fan-out (cache.ts). `?.`
114
+ // keeps a stale addon (built before a binding existed) a no-op.
115
+ ;(native as any).islandCacheInvalidate?.(msg.key, msg.tags)
116
+ ;(native as any).pageCacheInvalidate?.(msg.key, msg.tags)
117
+ ;(native as any).responseCacheInvalidate?.(msg.tags, msg.path, msg.method)
118
+ }
119
+
120
+ /** Publish an invalidation to peers. No-op when sync is not configured
121
+ * (BRUST_CACHE_SYNC_URL absent). Fire-and-forget: synchronous from the
122
+ * caller's view, failures warn (throttled) and never propagate — local
123
+ * invalidation must never depend on redis state. */
124
+ export function publishCacheSync(args: InvalidateArgs): void {
125
+ // invalidate({}) is a deliberate local no-op — don't broadcast it either.
126
+ if (!args.key && (!args.tags || args.tags.length === 0) && !args.path) return
127
+ const url = process.env.BRUST_CACHE_SYNC_URL
128
+ if (!url) return
129
+ const channel = process.env.BRUST_CACHE_SYNC_CHANNEL || CHANNEL_DEFAULT
130
+
131
+ const msg: CacheSyncMessage = { v: 1 }
132
+ const sender = process.env.BRUST_CACHE_SYNC_SENDER
133
+ if (sender) msg.sender = sender
134
+ if (args.key) msg.key = args.key
135
+ if (args.tags && args.tags.length > 0) msg.tags = args.tags
136
+ if (args.path) msg.path = args.path
137
+ if (args.method) msg.method = args.method
138
+
139
+ try {
140
+ const transport = testTransport ?? getPublisher(url)
141
+ transport.publish(channel, JSON.stringify(msg)).catch((e) => {
142
+ // Heal the lazy client: a publisher that died (redis restart) would
143
+ // otherwise be returned forever by getPublisher — drop it so the next
144
+ // publish reconnects.
145
+ if (transport === publisher) {
146
+ try {
147
+ publisher?.close()
148
+ } catch {
149
+ // best-effort
150
+ }
151
+ publisher = null
152
+ publisherUrl = null
153
+ }
154
+ warnThrottled(
155
+ 'publish',
156
+ `cache-sync: publish to ${redactUrl(url)} failed: ${scrubErrorMessage(e, url)}`,
157
+ )
158
+ })
159
+ } catch (e) {
160
+ warnThrottled(
161
+ 'publish',
162
+ `cache-sync: publish to ${redactUrl(url)} failed: ${scrubErrorMessage(e, url)}`,
163
+ )
164
+ }
165
+ }
166
+
167
+ function getPublisher(url: string): CacheSyncTransport {
168
+ if (!publisher || publisherUrl !== url) {
169
+ try {
170
+ publisher?.close()
171
+ } catch {
172
+ // best-effort
173
+ }
174
+ publisher = new RedisClient(url)
175
+ publisherUrl = url
176
+ }
177
+ return publisher
178
+ }
179
+
180
+ /** Start the subscriber (main isolate only — run() guards; workers never call
181
+ * this). Idempotent; never throws. A down redis at boot logs a redacted warn
182
+ * and retries with capped exponential backoff — the server boots regardless. */
183
+ export function startCacheSync(opts: { url: string; channel?: string }): void {
184
+ if (started) return
185
+ started = true
186
+ stopped = false
187
+ backoffMs = BACKOFF_BASE_MS
188
+ const channel = opts.channel || process.env.BRUST_CACHE_SYNC_CHANNEL || CHANNEL_DEFAULT
189
+ void attempt(opts.url, channel)
190
+ }
191
+
192
+ async function attempt(url: string, channel: string): Promise<void> {
193
+ if (stopped) return
194
+ const client = new RedisClient(url)
195
+ subscriber = client
196
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined
197
+ try {
198
+ // Race the subscribe against a timeout: against a down host the promise
199
+ // NEVER settles (see header) — without the race a down redis at boot
200
+ // would silently disable the feature forever. The no-op catch keeps a
201
+ // late loser settling AFTER the timeout from becoming an unhandled
202
+ // rejection.
203
+ const subscribed = client.subscribe(channel, onMessage)
204
+ subscribed.catch(() => {})
205
+ await Promise.race([
206
+ subscribed,
207
+ new Promise((_, reject) => {
208
+ timeoutTimer = setTimeout(() => reject(new Error('connect timeout')), CONNECT_TIMEOUT_MS)
209
+ timeoutTimer.unref?.()
210
+ }),
211
+ ])
212
+ // Identity guard: a slow connect can settle AFTER the timeout already
213
+ // scheduled a retry and a newer attempt replaced `subscriber` — this
214
+ // stale winner must stand down, not double-subscribe.
215
+ if (stopped || subscriber !== client) {
216
+ try {
217
+ client.close()
218
+ } catch {
219
+ // best-effort
220
+ }
221
+ return
222
+ }
223
+ backoffMs = BACKOFF_BASE_MS
224
+ // No auto-resubscribe after a drop (see header) — re-create the client.
225
+ client.onclose = () => {
226
+ if (!stopped && subscriber === client) scheduleRetry(url, channel)
227
+ }
228
+ } catch (e) {
229
+ try {
230
+ client.close()
231
+ } catch {
232
+ // best-effort
233
+ }
234
+ warnThrottled(
235
+ 'connect',
236
+ `cache-sync: redis unreachable at ${redactUrl(url)}, retrying (${scrubErrorMessage(e, url)})`,
237
+ )
238
+ if (subscriber === client) scheduleRetry(url, channel)
239
+ } finally {
240
+ if (timeoutTimer !== undefined) clearTimeout(timeoutTimer)
241
+ }
242
+ }
243
+
244
+ function scheduleRetry(url: string, channel: string): void {
245
+ if (stopped || retryTimer) return
246
+ retryTimer = setTimeout(() => {
247
+ retryTimer = null
248
+ void attempt(url, channel)
249
+ }, backoffMs)
250
+ // A dead redis must never hold the process open past drain.
251
+ retryTimer.unref?.()
252
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS)
253
+ }
254
+
255
+ function onMessage(message: string, _channel: string): void {
256
+ let parsed: CacheSyncMessage
257
+ try {
258
+ parsed = JSON.parse(message)
259
+ } catch {
260
+ warnThrottled('parse', 'cache-sync: dropping unparseable message')
261
+ return
262
+ }
263
+ applyCacheSyncMessage(parsed)
264
+ }
265
+
266
+ /** Shutdown/test hook: stop retries, close both clients, reset state so tests
267
+ * can restart. Wired into gracefulExit so backoff timers can't fire between
268
+ * drain and exit. */
269
+ export function stopCacheSync(): void {
270
+ stopped = true
271
+ started = false
272
+ if (retryTimer) {
273
+ clearTimeout(retryTimer)
274
+ retryTimer = null
275
+ }
276
+ try {
277
+ subscriber?.close()
278
+ } catch {
279
+ // best-effort
280
+ }
281
+ subscriber = null
282
+ try {
283
+ publisher?.close()
284
+ } catch {
285
+ // best-effort
286
+ }
287
+ publisher = null
288
+ publisherUrl = null
289
+ backoffMs = BACKOFF_BASE_MS
290
+ lastWarnAt.clear()
291
+ }
package/runtime/cache.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Dev-facing island ISR cache control. Invalidation crosses to the Rust-side
2
2
  // store (shared across the worker pool) via NAPI. Call from action/api/loader.
3
+ import { publishCacheSync } from './cache-sync.ts'
3
4
  import * as native from './index.js'
4
5
 
5
6
  export interface InvalidateArgs {
@@ -28,5 +29,8 @@ export const cache = {
28
29
  // Fan out to the L1 response cache by tag (route must declare cache.tags)
29
30
  // and/or by path. `?.` keeps a stale addon a no-op.
30
31
  ;(native as any).responseCacheInvalidate?.(args.tags, args.path, args.method)
32
+ // R9: propagate to peer processes when cache-sync is configured (no-op
33
+ // otherwise). Fire-and-forget — local invalidation never depends on redis.
34
+ publishCacheSync(args)
31
35
  },
32
36
  }
@@ -100,6 +100,13 @@ export async function runDev(args: string[]): Promise<void> {
100
100
  }[],
101
101
  outDir: jinjaDir,
102
102
  repoRoot: REPO_ROOT,
103
+ // R14 — dev-loop incremental compile: per-route content-hash memo over the
104
+ // resolved compileJsx inputs (route source + transitive local imports +
105
+ // lucide/directive env); an unchanged route skips recompile on hot reload.
106
+ // The boot emit below is incremental too — first run always misses (memo is
107
+ // per-process), so it just SEEDS the memo and the very first edit benefits.
108
+ // `brust build` never sets this flag (full-fidelity).
109
+ incremental: true,
103
110
  }
104
111
  // md routes piggyback on the same emit sites (task 2.8): templates land in
105
112
  // the SAME jinja dir, the frozen manifest next to it in `.brust/` (the dist
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
3
  import { createRequire } from 'node:module'
3
4
  import { dirname, relative, resolve } from 'node:path'
@@ -290,6 +291,89 @@ export interface NativeRouteEmitOpts {
290
291
  /** Repo root. Retained for call-site compatibility; native compilation now
291
292
  * goes through the napi addon's `compileJsx`, not a target/ binary. */
292
293
  repoRoot: string
294
+ /** Dev-loop incremental compile (R14). When true, each route's resolved
295
+ * compileJsx inputs (route source + every transitively imported local source
296
+ * + the lucide/directive/component-source env) are content-hashed and memoized
297
+ * for the lifetime of the process; an unchanged route SKIPS compileJsx and the
298
+ * sidecar rewrites (the previous emit's outputs are already on disk) but still
299
+ * appears in the returned manifest. ANY error in hashing falls back to a full
300
+ * compile — correctness over speed. Set only by `brust dev`'s emit calls;
301
+ * `brust build` stays full-fidelity (default false → no memo read OR write,
302
+ * and any stale memo entry for the route is dropped). */
303
+ incremental?: boolean
304
+ /** TEST SEAM — replaces the canonical-input hasher so tests can prove the
305
+ * hash-failure → compile-all fallback. Never set outside tests. */
306
+ hashInputsForTest?: (canonicalInputs: string) => string
307
+ }
308
+
309
+ /** Per-route emit outcome counts for `emitNativeTemplates` — testability seam
310
+ * for the dev-loop incremental memo (R14). `compiled + skipped` = routes
311
+ * emitted (routes dropped for a missing import count in neither). */
312
+ export interface NativeEmitStats {
313
+ compiled: number
314
+ skipped: number
315
+ }
316
+
317
+ /** Dev-session memo for the incremental path: `outDir\0templateName` →
318
+ * { hash of the resolved compileJsx inputs, output files written by the last
319
+ * compile }. In-memory only (per dev process, by design — no persistence). */
320
+ const nativeEmitMemo = new Map<string, { hash: string; outputs: string[] }>()
321
+
322
+ /** Clear the incremental memo (test isolation). */
323
+ export function resetNativeEmitMemo(): void {
324
+ nativeEmitMemo.clear()
325
+ }
326
+
327
+ /** Key-sorted shallow copy so JSON.stringify is order-independent (gather order
328
+ * is deterministic per content, but sorting makes the hash robust to it). */
329
+ function sortRecord(rec: Record<string, string>): Record<string, string> {
330
+ return Object.fromEntries(Object.entries(rec).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
331
+ }
332
+
333
+ /** Canonicalize EVERYTHING that feeds a route's compile + post-processing:
334
+ * the exact compileJsx arguments (route source/path, transitive component
335
+ * sources, lucide icons, directive names), the resolved import refs (they shape
336
+ * the .islands.json/.components.json/.factory.ts sidecars), and the per-emit
337
+ * env that mutates the template after compile (directive force-bake, generator
338
+ * meta, dev-client splice). If it can change the bytes on disk, it is in here. */
339
+ function canonicalCompileInputs(input: {
340
+ routeSource: string
341
+ routeSourcePath: string
342
+ sources: Record<string, string>
343
+ lucideIcons: Record<string, string>
344
+ directiveNames: Record<string, string>
345
+ mergedImports: Map<string, ResolvedImport>
346
+ hasDirectives: boolean
347
+ generatorMeta: string
348
+ devClient: boolean
349
+ }): string {
350
+ return JSON.stringify({
351
+ routeSource: input.routeSource,
352
+ routeSourcePath: input.routeSourcePath,
353
+ sources: sortRecord(input.sources),
354
+ lucideIcons: sortRecord(input.lucideIcons),
355
+ directiveNames: sortRecord(input.directiveNames),
356
+ imports: [...input.mergedImports.entries()]
357
+ .map(
358
+ ([ident, ref]) =>
359
+ [ident, ref.spec, ref.bare, ref.kind, ref.imported ?? ''] as [
360
+ string,
361
+ string,
362
+ boolean,
363
+ string,
364
+ string,
365
+ ],
366
+ )
367
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
368
+ hasDirectives: input.hasDirectives,
369
+ generatorMeta: input.generatorMeta,
370
+ devClient: input.devClient,
371
+ })
372
+ }
373
+
374
+ /** Default canonical-input hasher (overridable per-call via the test seam). */
375
+ function sha256Hex(canonicalInputs: string): string {
376
+ return createHash('sha256').update(canonicalInputs).digest('hex')
293
377
  }
294
378
 
295
379
  /** Raw component entry from compileJsx's `componentsJson` field. camelCase keys
@@ -478,7 +562,7 @@ export function emitComponentArtifacts(
478
562
  return { islandIdsFromComponents }
479
563
  }
480
564
 
481
- export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<void> {
565
+ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<NativeEmitStats> {
482
566
  mkdirSync(opts.outDir, { recursive: true })
483
567
 
484
568
  // md-route exclusion lives HERE (not at the call sites): a chain whose LEAF
@@ -535,6 +619,7 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
535
619
  const generatorMeta = resolveGenerator(opts.outDir).meta
536
620
 
537
621
  const built: string[] = []
622
+ const stats: NativeEmitStats = { compiled: 0, skipped: 0 }
538
623
  for (const r of nativeRoutes) {
539
624
  const name = r.nativeTemplate!
540
625
  const sourcePath = importMap.get(name)
@@ -612,6 +697,44 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
612
697
  }
613
698
  }
614
699
 
700
+ // R14 — dev incremental memo. EVERYTHING hashed here is the resolved input
701
+ // set: the gather steps above re-read every transitive local source fresh on
702
+ // each emit, so an edit anywhere in the route's import graph changes
703
+ // `sources` (or lucide/directive env) and misses the memo. ANY hashing error
704
+ // → undefined → compile (correctness over speed). Non-incremental calls
705
+ // (brust build, boot staleness) never read the memo and DROP the route's
706
+ // entry below, so a later incremental emit can't trust outputs it didn't
707
+ // verify against this exact hash.
708
+ const memoKey = `${opts.outDir}\0${name}`
709
+ let inputsHash: string | undefined
710
+ if (opts.incremental) {
711
+ try {
712
+ inputsHash = (opts.hashInputsForTest ?? sha256Hex)(
713
+ canonicalCompileInputs({
714
+ routeSource,
715
+ routeSourcePath,
716
+ sources,
717
+ lucideIcons,
718
+ directiveNames,
719
+ mergedImports,
720
+ hasDirectives,
721
+ generatorMeta,
722
+ devClient: process.env.BRUST_DEV === '1',
723
+ }),
724
+ )
725
+ } catch {
726
+ inputsHash = undefined
727
+ }
728
+ const prev = inputsHash !== undefined ? nativeEmitMemo.get(memoKey) : undefined
729
+ if (prev && prev.hash === inputsHash && prev.outputs.every((p) => existsSync(p))) {
730
+ // Unchanged route: the previous emit's .jinja + sidecars are on disk —
731
+ // skip compileJsx and every rewrite, but still report the template.
732
+ built.push(name)
733
+ stats.skipped++
734
+ continue
735
+ }
736
+ }
737
+
615
738
  let compiled: { template: string; islandsJson: string; warnings?: string[] }
616
739
  try {
617
740
  compiled = compileJsx!(routeSource, routeSourcePath, sources, lucideIcons, directiveNames)
@@ -644,6 +767,8 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
644
767
  process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withGenerator) : withGenerator
645
768
  writeFileSync(outPath, template)
646
769
  built.push(name)
770
+ stats.compiled++
771
+ const outputs = [outPath]
647
772
 
648
773
  // Islands post-processing. The compiler reports an island manifest ONLY
649
774
  // when the route uses <Island>; `"[]"` ⇒ no islands ⇒ leave the .jinja
@@ -653,6 +778,7 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
653
778
  if (compiled.islandsJson && compiled.islandsJson !== '[]') {
654
779
  writeFileSync(islandsJsonPath, compiled.islandsJson)
655
780
  reconcileIslandManifest(outPath, islandsJsonPath, mergedImports, name)
781
+ outputs.push(islandsJsonPath)
656
782
  } else if (existsSync(islandsJsonPath)) {
657
783
  rmSync(islandsJsonPath, { force: true })
658
784
  }
@@ -661,6 +787,19 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
661
787
  const compJsonStr = (compiled as any).componentsJson ?? '[]'
662
788
  if (compJsonStr !== '[]') {
663
789
  emitComponentArtifacts(outPath, compJsonStr, mergedImports, name)
790
+ outputs.push(
791
+ outPath.replace(/\.jinja$/, '.components.json'),
792
+ outPath.replace(/\.jinja$/, '.factory.ts'),
793
+ )
794
+ }
795
+
796
+ // R14 — memoize only what was hashed AND written by an incremental call;
797
+ // a non-incremental compile (build/boot) invalidates the entry instead
798
+ // (its writes weren't checked against any hash → never trust-skip them).
799
+ if (opts.incremental && inputsHash !== undefined) {
800
+ nativeEmitMemo.set(memoKey, { hash: inputsHash, outputs })
801
+ } else {
802
+ nativeEmitMemo.delete(memoKey)
664
803
  }
665
804
  }
666
805
 
@@ -668,6 +807,13 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
668
807
  resolve(opts.outDir, '_manifest.json'),
669
808
  JSON.stringify({ templates: built, generatedAt: new Date().toISOString() }, null, 2),
670
809
  )
810
+
811
+ if (opts.incremental && nativeRoutes.length > 0) {
812
+ console.log(
813
+ `[brust] dev: native templates — ${stats.compiled} compiled, ${stats.skipped} unchanged (skipped)`,
814
+ )
815
+ }
816
+ return stats
671
817
  }
672
818
 
673
819
  /** A JSX SSR-component ident is always Capitalized — `<Search/>` lowers to an
package/runtime/config.ts CHANGED
@@ -14,6 +14,12 @@ export interface BrustConfig {
14
14
  cacheMaxEntries?: number
15
15
  /** L2 page-cache capacity (entries). Undefined → Rust default of 1000. */
16
16
  cachePageMaxEntries?: number
17
+ /** R9 cross-process cache invalidation: redis/dragonfly URL. Absent →
18
+ * feature disabled (current single-process behavior). */
19
+ cacheSyncUrl?: string
20
+ /** Pub/sub channel for cache invalidation. Undefined → module default
21
+ * (`brust:cache:invalidate`). */
22
+ cacheSyncChannel?: string
17
23
  }
18
24
 
19
25
  /** Caller-supplied fallbacks (e.g. `brust.run({ address, port })`) applied
@@ -85,6 +91,8 @@ export async function loadConfig(
85
91
  workers,
86
92
  cacheMaxEntries: fromToml.cacheMaxEntries,
87
93
  cachePageMaxEntries: fromToml.cachePageMaxEntries,
94
+ cacheSyncUrl: fromEnv.cacheSyncUrl ?? fromToml.cacheSyncUrl,
95
+ cacheSyncChannel: fromEnv.cacheSyncChannel ?? fromToml.cacheSyncChannel,
88
96
  }
89
97
  }
90
98
 
@@ -168,6 +176,26 @@ function extractFromToml(parsed: unknown, file: string): Partial<BrustConfig> {
168
176
  }
169
177
  out.cachePageMaxEntries = pageMaxEntries
170
178
  }
179
+ const syncUrl = (cache as Record<string, unknown>).sync_url
180
+ if (syncUrl !== undefined) {
181
+ if (typeof syncUrl !== 'string' || syncUrl.trim() === '') {
182
+ throw new BrustConfigError(
183
+ `${file}: cache.sync_url must be a non-empty string (got ${JSON.stringify(syncUrl)})`,
184
+ file,
185
+ )
186
+ }
187
+ out.cacheSyncUrl = syncUrl.trim()
188
+ }
189
+ const syncChannel = (cache as Record<string, unknown>).sync_channel
190
+ if (syncChannel !== undefined) {
191
+ if (typeof syncChannel !== 'string' || syncChannel.trim() === '') {
192
+ throw new BrustConfigError(
193
+ `${file}: cache.sync_channel must be a non-empty string (got ${JSON.stringify(syncChannel)})`,
194
+ file,
195
+ )
196
+ }
197
+ out.cacheSyncChannel = syncChannel.trim()
198
+ }
171
199
  }
172
200
 
173
201
  return out
@@ -202,5 +230,19 @@ function extractFromEnv(): Partial<BrustConfig> {
202
230
  }
203
231
  out.workers = n
204
232
  }
233
+ if (process.env.BRUST_CACHE_SYNC_URL) {
234
+ const url = process.env.BRUST_CACHE_SYNC_URL.trim()
235
+ if (url === '') {
236
+ throw new BrustConfigError('BRUST_CACHE_SYNC_URL must be a non-empty string', null)
237
+ }
238
+ out.cacheSyncUrl = url
239
+ }
240
+ if (process.env.BRUST_CACHE_SYNC_CHANNEL) {
241
+ const channel = process.env.BRUST_CACHE_SYNC_CHANNEL.trim()
242
+ if (channel === '') {
243
+ throw new BrustConfigError('BRUST_CACHE_SYNC_CHANNEL must be a non-empty string', null)
244
+ }
245
+ out.cacheSyncChannel = channel
246
+ }
205
247
  return out
206
248
  }