brustjs 0.1.50-alpha → 0.1.51-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/package.json +39 -15
- package/runtime/cache-sync.ts +291 -0
- package/runtime/cache.ts +4 -0
- package/runtime/cli/dev.ts +7 -0
- package/runtime/cli/native-routes-emit.ts +147 -1
- package/runtime/config.ts +42 -0
- package/runtime/index.d.ts +63 -0
- package/runtime/index.js +57 -52
- package/runtime/index.ts +108 -9
- package/runtime/native/runtime.ts +220 -7
- package/runtime/render/fragment.ts +87 -0
- package/runtime/routes.ts +225 -48
- package/runtime/templates.ts +47 -0
- package/runtime/treaty.ts +24 -1
- package/types/action-error.d.ts +18 -0
- package/types/cache-sync.d.ts +42 -0
- package/types/cache.d.ts +20 -0
- package/types/cli/help.d.ts +28 -0
- package/types/cli/jinja-staleness.d.ts +14 -0
- package/types/cli/native-routes-emit.d.ts +217 -0
- package/types/cli/new.d.ts +30 -0
- package/types/cli/templates.d.ts +39 -0
- package/types/client/index.d.ts +14 -0
- package/types/config.d.ts +42 -0
- package/types/cookies.d.ts +25 -0
- package/types/create.d.ts +1 -0
- package/types/css/build.d.ts +11 -0
- package/types/css/component-build.d.ts +17 -0
- package/types/css/component-loader.d.ts +8 -0
- package/types/css/manifest.d.ts +21 -0
- package/types/css/process-modules.d.ts +31 -0
- package/types/css/route-deps.d.ts +20 -0
- package/types/css/scan-imports.d.ts +13 -0
- package/types/css.d.ts +16 -0
- package/types/define-actions.d.ts +133 -0
- package/types/dev/client.d.ts +8 -0
- package/types/dev/coordinator.d.ts +33 -0
- package/types/dev/inject.d.ts +6 -0
- package/types/dev/jinja-reload.d.ts +7 -0
- package/types/dev/tui.d.ts +35 -0
- package/types/dev/watcher.d.ts +34 -0
- package/types/dev/worker-registry.d.ts +17 -0
- package/types/dev/ws-channel.d.ts +39 -0
- package/types/generator.d.ts +23 -0
- package/types/index.d.ts +222 -0
- package/types/islands/brust-page.d.ts +74 -0
- package/types/islands/build.d.ts +49 -0
- package/types/islands/chunk-id.d.ts +10 -0
- package/types/islands/importmap.d.ts +2 -0
- package/types/islands/island.d.ts +65 -0
- package/types/islands/isr-jsx.d.ts +31 -0
- package/types/islands/native-render.d.ts +89 -0
- package/types/loader-cache.d.ts +18 -0
- package/types/mcp/extractor.d.ts +14 -0
- package/types/mcp/manifest.d.ts +23 -0
- package/types/mcp/schema.d.ts +19 -0
- package/types/mcp/server.d.ts +15 -0
- package/types/md/emit.d.ts +72 -0
- package/types/md/render.d.ts +80 -0
- package/types/md/routes.d.ts +119 -0
- package/types/md/scan.d.ts +34 -0
- package/types/md/slug.d.ts +1 -0
- package/types/native/build.d.ts +30 -0
- package/types/native/index.d.ts +2 -0
- package/types/native/runtime.d.ts +52 -0
- package/types/navigation/active-nav.d.ts +2 -0
- package/types/navigation/index.d.ts +5 -0
- package/types/navigation/navigate.d.ts +14 -0
- package/types/navigation/react.d.ts +15 -0
- package/types/navigation/store.d.ts +44 -0
- package/types/render/fragment.d.ts +20 -0
- package/types/render/inject-action-prefix.d.ts +9 -0
- package/types/render/inject-css-link.d.ts +8 -0
- package/types/render/inject-dev-client.d.ts +6 -0
- package/types/render/inject-generator.d.ts +7 -0
- package/types/render/inject-store.d.ts +9 -0
- package/types/render/stream.d.ts +45 -0
- package/types/request-context.d.ts +16 -0
- package/types/routes.d.ts +506 -0
- package/types/sse/handler.d.ts +22 -0
- package/types/standard-schema.d.ts +31 -0
- package/types/store/define-store.d.ts +31 -0
- package/types/store/index.d.ts +5 -0
- package/types/store/react.d.ts +2 -0
- package/types/store/serialize.d.ts +5 -0
- package/types/store/server-context.d.ts +4 -0
- package/types/store/signal.d.ts +18 -0
- package/types/templates.d.ts +18 -0
- package/types/treaty.d.ts +70 -0
- 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.
|
|
3
|
+
"version": "0.1.51-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.
|
|
45
|
-
"brustjs-darwin-arm64": "0.1.
|
|
46
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
48
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
49
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
44
|
+
"brustjs-darwin-x64": "0.1.51-alpha",
|
|
45
|
+
"brustjs-darwin-arm64": "0.1.51-alpha",
|
|
46
|
+
"brustjs-linux-x64-gnu": "0.1.51-alpha",
|
|
47
|
+
"brustjs-linux-arm64-gnu": "0.1.51-alpha",
|
|
48
|
+
"brustjs-linux-x64-musl": "0.1.51-alpha",
|
|
49
|
+
"brustjs-linux-arm64-musl": "0.1.51-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": "./
|
|
74
|
+
"types": "./types/index.d.ts",
|
|
75
75
|
"exports": {
|
|
76
|
-
".":
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"./
|
|
81
|
-
|
|
82
|
-
|
|
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
|
}
|
package/runtime/cli/dev.ts
CHANGED
|
@@ -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<
|
|
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
|
}
|