@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.3
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/native/index.d.ts +28 -0
- package/native/index.js +57 -54
- package/package.json +14 -8
- package/src/actions-bridge.ts +27 -9
- package/src/build/bundle.ts +12 -2
- package/src/build/cf-assets.ts +81 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +72 -0
- package/src/build/cf-wrangler.ts +167 -0
- package/src/build/cloudflare.ts +310 -0
- package/src/build/config.ts +213 -4
- package/src/build/index.ts +75 -7
- package/src/build/manifest.ts +18 -0
- package/src/cache-adapter/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +18 -0
- package/src/cli.ts +35 -2
- package/src/config.ts +16 -0
- package/src/dev.ts +110 -0
- package/src/handlers.ts +243 -0
- package/src/native.ts +20 -1
- package/src/run.ts +6 -1
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +43 -231
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The cache adapter at run time, on both hosts: the per-process (native) / per-isolate (Workers)
|
|
2
|
+
// adapter, what `cache.invalidate` does after the tier-1 primitive, and the native worker-0
|
|
3
|
+
// subscriber that replays other processes' invalidations. No `bun` import (CF-bundle safe).
|
|
4
|
+
//
|
|
5
|
+
// L3: a replayed event is applied through the host's REPLAY primitive (napi `cacheReplay`, wasm
|
|
6
|
+
// `core.l2_replay`), never through `cache.invalidate`, so it is never published again.
|
|
7
|
+
import type { CacheAdapter, InvalidateArgs, InvalidationEvent } from './types'
|
|
8
|
+
|
|
9
|
+
export interface CacheRuntime {
|
|
10
|
+
adapter: CacheAdapter
|
|
11
|
+
/** This process / isolate: events it published carry it and are not replayed here. */
|
|
12
|
+
origin: string
|
|
13
|
+
/** Keeps a background promise alive (Workers: `waitUntil`; native: a swallowed floating promise). */
|
|
14
|
+
defer: (p: Promise<unknown>) => void
|
|
15
|
+
/** Bumps a `stats.l2` counter (napi `cacheL2Count` / wasm `rt.l2Count`). */
|
|
16
|
+
count: (name: string) => void
|
|
17
|
+
/** Resolves once the adapter's `init` ran (Workers defer it to the first use). */
|
|
18
|
+
ready?: () => Promise<void>
|
|
19
|
+
/**
|
|
20
|
+
* The host's replay primitive (napi `cacheReplay` / wasm `rt.replay` → `Core::l2_replay`): tier 1
|
|
21
|
+
* again plus the floor at `seq` (`null` = tier 1 only). Never publishes.
|
|
22
|
+
*/
|
|
23
|
+
replay?: (args: InvalidateArgs, seq: number | null) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let current: CacheRuntime | null = null
|
|
27
|
+
|
|
28
|
+
/** Installed at worker boot (native) / `install` (Workers); `null` uninstalls (tests). */
|
|
29
|
+
export function setCacheRuntime(r: CacheRuntime | null): void {
|
|
30
|
+
current = r
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function cacheRuntime(): CacheRuntime | null {
|
|
34
|
+
return current
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const describe = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* After `cache.invalidate`'s tier-1 primitive: the shared-side delete, THEN the publish (ruling
|
|
41
|
+
* L7a: other instances replay only once the store no longer holds the bodies), deferred off the
|
|
42
|
+
* request path; THEN this instance replays its own invalidation once (review F1): a request in the
|
|
43
|
+
* window before the delete landed could re-fill tier 1 from the old L2 body, and its own echo is
|
|
44
|
+
* skipped, so the replay re-clears tier 1 and raises the floor to the publish seq (a re-filled
|
|
45
|
+
* body is older). Never throws, never leaves a rejected promise (a worker-side unhandled rejection
|
|
46
|
+
* is fatal on native). Option values are never logged.
|
|
47
|
+
*/
|
|
48
|
+
export function afterInvalidate(args: InvalidateArgs): void {
|
|
49
|
+
const r = current
|
|
50
|
+
if (!r || (!r.adapter.store && !r.adapter.broadcast)) return
|
|
51
|
+
const { adapter, origin, count } = r
|
|
52
|
+
const task = (async () => {
|
|
53
|
+
await r.ready?.()
|
|
54
|
+
if (adapter.store) {
|
|
55
|
+
try {
|
|
56
|
+
await adapter.store.invalidate(args)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
count('errors')
|
|
59
|
+
console.error(`[brust] cache adapter "${adapter.name}": store.invalidate failed: ${describe(e)}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let seq: number | null = null
|
|
63
|
+
if (adapter.broadcast) {
|
|
64
|
+
try {
|
|
65
|
+
seq = await adapter.broadcast.publish(args, origin)
|
|
66
|
+
count('published')
|
|
67
|
+
} catch (e) {
|
|
68
|
+
count('publish_errors')
|
|
69
|
+
console.error(`[brust] cache adapter "${adapter.name}": publish failed: ${describe(e)}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
r.replay?.(args, typeof seq === 'number' && Number.isSafeInteger(seq) && seq >= 0 ? seq : null)
|
|
74
|
+
} catch (e) {
|
|
75
|
+
count('errors')
|
|
76
|
+
console.error(`[brust] cache adapter "${adapter.name}": own replay failed: ${describe(e)}`)
|
|
77
|
+
}
|
|
78
|
+
})()
|
|
79
|
+
r.defer(task.catch(() => {}))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SubscriberDeps {
|
|
83
|
+
/** napi `cacheReplay(args, seq)`: tier 1 + the floor, never re-published. */
|
|
84
|
+
replay: (args: InvalidateArgs, seq: number) => void
|
|
85
|
+
/** napi `cacheClear()`: both tier-1 caches dropped (a gap). */
|
|
86
|
+
clear: () => void
|
|
87
|
+
count: (name: string) => void
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Native worker 0's push subscriber (R10): first event adopts its seq; `seq <= last` is ignored;
|
|
92
|
+
* `seq > last + 1` clears tier 1 then adopts; `onGap` clears and forgets. Own events advance the
|
|
93
|
+
* seq but are not replayed (tier 1 is already invalidated). Resolves to the unsubscribe.
|
|
94
|
+
*/
|
|
95
|
+
export async function startSubscriber(adapter: CacheAdapter, origin: string, deps: SubscriberDeps): Promise<(() => Promise<void>) | null> {
|
|
96
|
+
const sub = adapter.broadcast?.subscribe
|
|
97
|
+
if (!sub) return null
|
|
98
|
+
let last: number | null = null
|
|
99
|
+
const on = (e: InvalidationEvent) => {
|
|
100
|
+
try {
|
|
101
|
+
if (last !== null && e.seq <= last) return
|
|
102
|
+
if (last !== null && e.seq > last + 1) deps.clear()
|
|
103
|
+
last = e.seq
|
|
104
|
+
if (e.origin === origin) {
|
|
105
|
+
deps.count('skipped_own')
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
deps.replay(e.args, e.seq)
|
|
109
|
+
deps.count('received')
|
|
110
|
+
} catch (err) {
|
|
111
|
+
deps.count('errors')
|
|
112
|
+
console.error(`[brust] cache adapter "${adapter.name}": replay failed: ${describe(err)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const onGap = () => {
|
|
116
|
+
try {
|
|
117
|
+
deps.clear()
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error(`[brust] cache adapter "${adapter.name}": clear failed: ${describe(err)}`)
|
|
120
|
+
}
|
|
121
|
+
last = null
|
|
122
|
+
}
|
|
123
|
+
return sub.call(adapter.broadcast, on, onGap)
|
|
124
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The cache adapter contract (docs/design/2026-10-12-cache-adapter-design.md §3.1): a shared second
|
|
2
|
+
// tier behind brust's in-process cache. An adapter has two optional capabilities — a `store` of
|
|
3
|
+
// rendered pages and a `broadcast` of invalidations (push `subscribe` or pull `since`) — so a store
|
|
4
|
+
// without a channel (KV) can be composed with another adapter's broadcast (DO).
|
|
5
|
+
//
|
|
6
|
+
// One sequence space per adapter: every `stamp`, `entryStamp` and event `seq` is a number in its
|
|
7
|
+
// broadcast's sequence (a store without a broadcast reports `null` stamps and its writes are unguarded).
|
|
8
|
+
import type { InvalidateArgs } from '../native'
|
|
9
|
+
|
|
10
|
+
export type { InvalidateArgs }
|
|
11
|
+
|
|
12
|
+
/** A rendered page: the identity body and its validated headers, in order (repeats kept). */
|
|
13
|
+
export interface StoredPage {
|
|
14
|
+
status: number
|
|
15
|
+
headers: [string, string][]
|
|
16
|
+
body: Uint8Array
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SetMeta {
|
|
20
|
+
ttlMs: number
|
|
21
|
+
tags: string[]
|
|
22
|
+
method: string
|
|
23
|
+
path: string
|
|
24
|
+
/** The `stamp` the lookup returned (read before the render); `null` = unguarded write. */
|
|
25
|
+
stamp: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GetResult {
|
|
29
|
+
page: StoredPage | null
|
|
30
|
+
/** The broadcast seq NOW: the write-guard base of the set that may follow this miss. */
|
|
31
|
+
stamp: number | null
|
|
32
|
+
/** The stamp the entry was written under (`SetMeta.stamp`); the invalidation floor compares it. */
|
|
33
|
+
entryStamp: number | null
|
|
34
|
+
/** When the entry was written (ms since the epoch); `null` = unknown (full ttl). */
|
|
35
|
+
storedAt: number | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CacheStore {
|
|
39
|
+
get(key: string): Promise<GetResult>
|
|
40
|
+
/**
|
|
41
|
+
* `false` = refused: the stamp is older than an invalidation of one of `meta.tags` or of
|
|
42
|
+
* `meta.method meta.path` (a store with atomic writes MUST refuse it), or the body is over `maxBodyBytes`.
|
|
43
|
+
*/
|
|
44
|
+
set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean>
|
|
45
|
+
/**
|
|
46
|
+
* Shared-side delete by tags/path (`key` is a no-op: the store holds pages only). Records the
|
|
47
|
+
* invalidation marker BEFORE deleting, so a concurrent stale `set` is refused even before the
|
|
48
|
+
* publish (ruling L7b).
|
|
49
|
+
*/
|
|
50
|
+
invalidate(args: InvalidateArgs): Promise<void>
|
|
51
|
+
readonly maxBodyBytes: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InvalidationEvent {
|
|
55
|
+
seq: number
|
|
56
|
+
origin: string
|
|
57
|
+
args: InvalidateArgs
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EpochSince {
|
|
61
|
+
/** The broadcast's generation: a change means its history is gone (every reader clears). */
|
|
62
|
+
gen: string
|
|
63
|
+
/** The latest seq. */
|
|
64
|
+
seq: number
|
|
65
|
+
/** The reader is further behind than the retained history: it must clear. */
|
|
66
|
+
behind: boolean
|
|
67
|
+
/** Every retained event with a seq above the one asked for, in order. */
|
|
68
|
+
events: InvalidationEvent[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CacheBroadcast {
|
|
72
|
+
/** Append an invalidation; resolves to the seq it was assigned. */
|
|
73
|
+
publish(args: InvalidateArgs, origin: string): Promise<number>
|
|
74
|
+
/** Push: events in order; `onGap` when delivery may have lost some. Resolves to an unsubscribe. */
|
|
75
|
+
subscribe?(on: (e: InvalidationEvent) => void, onGap: () => void): Promise<() => Promise<void>>
|
|
76
|
+
/** Pull: the events after `seq` (Workers: the only mode, no connection outlives a request). */
|
|
77
|
+
since?(seq: number): Promise<EpochSince>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CacheAdapterInit {
|
|
81
|
+
namespace: string
|
|
82
|
+
buildId: string
|
|
83
|
+
runtime: 'bun' | 'workers'
|
|
84
|
+
workerId: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CacheAdapter {
|
|
88
|
+
readonly name: string
|
|
89
|
+
readonly runtimes: ReadonlyArray<'bun' | 'workers'>
|
|
90
|
+
store?: CacheStore
|
|
91
|
+
broadcast?: CacheBroadcast
|
|
92
|
+
init?(ctx: CacheAdapterInit): Promise<void>
|
|
93
|
+
close?(): Promise<void>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type CacheAdapterFactory = (options: Record<string, unknown>) => CacheAdapter
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The cache adapter inside a Workers isolate (cache adapters R11, R14): `install(rt, ext)` builds the
|
|
2
|
+
// adapter, wires `cache.invalidate`'s publish through `waitUntil`, and answers the wasm side's bridge
|
|
3
|
+
// (`crates/brust-wasm/src/host.rs` `CacheBridge`). No `bun` import (it is bundled into brust-app.js).
|
|
4
|
+
import { checkRuntime, compose, instantiate } from './load'
|
|
5
|
+
import { setCacheRuntime } from './runtime'
|
|
6
|
+
import type { CacheAdapter, InvalidateArgs, SetMeta } from './types'
|
|
7
|
+
|
|
8
|
+
/** What the wasm side passes to `install` (`bridge.rs`). */
|
|
9
|
+
export interface CfRuntimeBase {
|
|
10
|
+
l2Count?(name: string): boolean
|
|
11
|
+
/** `Core::l2_replay`: tier 1 + the floor at `seq`, never published (review F1's own replay). */
|
|
12
|
+
replay?(args: InvalidateArgs, seq: number | null): unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `CONFIG.cache` (build/cf-entry.ts), as the entry passes it. */
|
|
16
|
+
export interface CfCacheConfig {
|
|
17
|
+
adapter: string
|
|
18
|
+
getTimeoutMs: number
|
|
19
|
+
maxBodyBytes: number
|
|
20
|
+
namespace: string
|
|
21
|
+
buildId: string
|
|
22
|
+
epochIntervalMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The generated entry's second `install` argument. */
|
|
26
|
+
export interface CfInstallExt {
|
|
27
|
+
/** The adapter module's namespace, default export, or factory. */
|
|
28
|
+
adapter: unknown
|
|
29
|
+
options: Record<string, unknown>
|
|
30
|
+
/** A composed built-in broadcast (`[cache] broadcast`), same forms as `adapter`. */
|
|
31
|
+
broadcast?: unknown
|
|
32
|
+
broadcastOptions?: Record<string, unknown>
|
|
33
|
+
config: CfCacheConfig
|
|
34
|
+
waitUntil: (p: Promise<unknown>) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The wasm side's view: `get`/`set` only with a store, `since` only with a pull broadcast. */
|
|
38
|
+
export interface CfCacheBridge {
|
|
39
|
+
origin: string
|
|
40
|
+
get?: (key: string) => Promise<{ json: string; body: Uint8Array | null }>
|
|
41
|
+
set?: (key: string, json: string, body: Uint8Array) => void
|
|
42
|
+
since?: (seq: number) => Promise<string>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const describe = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
|
46
|
+
|
|
47
|
+
/** Throws (a TypeError → the Worker refuses to boot, lane-3 R11) on a bad adapter or a push-only broadcast. */
|
|
48
|
+
export function installCache(rt: CfRuntimeBase, ext: CfInstallExt): CfCacheBridge {
|
|
49
|
+
const { config: c, waitUntil } = ext
|
|
50
|
+
let a: CacheAdapter = instantiate(ext.adapter, ext.options, `"${c.adapter}"`)
|
|
51
|
+
if (ext.broadcast !== undefined) {
|
|
52
|
+
const other = instantiate(ext.broadcast, ext.broadcastOptions ?? {}, 'broadcast')
|
|
53
|
+
if (!other.broadcast) throw new TypeError(`cache adapter "${other.name}" has no broadcast to compose`)
|
|
54
|
+
a = compose(a, other)
|
|
55
|
+
}
|
|
56
|
+
checkRuntime(a, 'workers')
|
|
57
|
+
const count = (name: string) => void rt.l2Count?.(name)
|
|
58
|
+
// `init` may do I/O, which a Worker refuses at start: it runs on the first use, once.
|
|
59
|
+
let ready: Promise<void> | null = null
|
|
60
|
+
const ensure = () => (ready ??= Promise.resolve(a.init?.({ namespace: c.namespace, buildId: c.buildId, runtime: 'workers', workerId: 0 })))
|
|
61
|
+
// A Worker may not draw random values at start (global scope): the origin is made on first read.
|
|
62
|
+
let origin: string | null = null
|
|
63
|
+
const getOrigin = () => (origin ??= crypto.randomUUID())
|
|
64
|
+
setCacheRuntime({
|
|
65
|
+
adapter: a,
|
|
66
|
+
get origin() {
|
|
67
|
+
return getOrigin()
|
|
68
|
+
},
|
|
69
|
+
defer: (p) => waitUntil(p),
|
|
70
|
+
count,
|
|
71
|
+
ready: ensure,
|
|
72
|
+
replay: (args, seq) => void rt.replay?.(args, seq),
|
|
73
|
+
})
|
|
74
|
+
const store = a.store
|
|
75
|
+
const since = a.broadcast?.since?.bind(a.broadcast)
|
|
76
|
+
return {
|
|
77
|
+
get origin() {
|
|
78
|
+
return getOrigin()
|
|
79
|
+
},
|
|
80
|
+
...(store
|
|
81
|
+
? {
|
|
82
|
+
async get(key: string) {
|
|
83
|
+
try {
|
|
84
|
+
await ensure()
|
|
85
|
+
const r = await store.get(key)
|
|
86
|
+
if (!r.page || r.page.body.length > c.maxBodyBytes) {
|
|
87
|
+
if (r.page) count('errors')
|
|
88
|
+
return { json: JSON.stringify({ hit: false, stamp: r.stamp }), body: null }
|
|
89
|
+
}
|
|
90
|
+
const p = r.page
|
|
91
|
+
return { json: JSON.stringify({ hit: true, status: p.status, headers: p.headers, stamp: r.stamp, entryStamp: r.entryStamp, storedAt: r.storedAt }), body: p.body }
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { json: JSON.stringify({ error: describe(e) }), body: null }
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
set(key: string, json: string, body: Uint8Array) {
|
|
97
|
+
const task = (async () => {
|
|
98
|
+
const { status, headers, meta } = JSON.parse(json) as { status: number; headers: [string, string][]; meta: SetMeta }
|
|
99
|
+
await ensure()
|
|
100
|
+
count((await store.set(key, { status, headers, body }, meta)) ? 'sets' : 'set_refused')
|
|
101
|
+
})().catch((e) => {
|
|
102
|
+
count('errors')
|
|
103
|
+
console.error(`[brust] cache adapter "${a.name}": set failed: ${describe(e)}`)
|
|
104
|
+
})
|
|
105
|
+
waitUntil(task)
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
109
|
+
...(since
|
|
110
|
+
? {
|
|
111
|
+
async since(seq: number) {
|
|
112
|
+
await ensure()
|
|
113
|
+
return JSON.stringify(await since(seq))
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/cache.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Component-level cache declaration (S4 / §5). The compiler reads `cache(Comp, opts)` statically;
|
|
2
2
|
// at runtime it is identity. `cache.invalidate` drops L1 pages and job-cache entries in the
|
|
3
|
-
// running server (napi `cacheInvalidate`)
|
|
3
|
+
// running server (napi `cacheInvalidate`), then — with a cache adapter — the shared store and the
|
|
4
|
+
// broadcast to every other instance (deferred, `cache-adapter/runtime.ts`).
|
|
5
|
+
import { afterInvalidate } from './cache-adapter/runtime'
|
|
4
6
|
import { cacheComponent } from './cache-core'
|
|
5
7
|
import { type InvalidateArgs, type InvalidateResult, cacheInvalidate } from './native'
|
|
6
8
|
|
|
@@ -19,6 +21,7 @@ function invalidate(args: InvalidateArgs): InvalidateResult {
|
|
|
19
21
|
throw new Error(`${NO_SERVER}: ${msg}`)
|
|
20
22
|
throw e
|
|
21
23
|
}
|
|
24
|
+
afterInvalidate(args)
|
|
22
25
|
return { l1Removed: r.l1Removed, jobRemoved: r.jobRemoved }
|
|
23
26
|
}
|
|
24
27
|
|
package/src/cf.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// `@brust/core` under the `workerd` export condition (M4-CF, ruling c30e2633): the app-facing API a Worker can run.
|
|
2
|
+
// Same names as `index.ts` minus what needs a host process: no `run`, no `loadConfig`. `cache.invalidate` goes through
|
|
3
|
+
// the CF bundle's napi stub to the runtime the wasm glue installed at start (`server-handlers` `install`).
|
|
4
|
+
export { cache } from './cache'
|
|
5
|
+
export type { ComponentCacheOptions, InvalidateArgs, InvalidateResult } from './cache'
|
|
6
|
+
export {
|
|
7
|
+
ALLOWED_ROUTE_FIELDS,
|
|
8
|
+
BrustRouteError,
|
|
9
|
+
defineRoutes,
|
|
10
|
+
flattenRoutes,
|
|
11
|
+
httpError,
|
|
12
|
+
isHttpErrorTrigger,
|
|
13
|
+
isVerdict,
|
|
14
|
+
notFound,
|
|
15
|
+
Outlet,
|
|
16
|
+
redirect,
|
|
17
|
+
} from './routes'
|
|
18
|
+
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
|
package/src/cli.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// Exit codes: 0 ok, 1 build/boot error (`error <rule> <message>` / `[brust] …`), 2 usage.
|
|
4
4
|
import { parseArgs } from 'node:util'
|
|
5
5
|
import { BuildError, runBuild } from './build'
|
|
6
|
+
import { runDev } from './dev'
|
|
6
7
|
import { run } from './run'
|
|
7
8
|
|
|
8
9
|
const USAGE = `usage:
|
|
9
|
-
brust build [entry=routes.tsx] [--out-dir dist] [--force]
|
|
10
|
+
brust build [entry=routes.tsx] [--out-dir dist] [--force] [--target bun|cloudflare] [--wasm-from-source]
|
|
11
|
+
brust dev [entry=routes.tsx] --target cloudflare [--out-dir dist] [--port N] [--wasm-from-source] [-- <wrangler args>]
|
|
10
12
|
brust start [--port N] [--workers N] [--dist-dir dist] [--entry routes.tsx]
|
|
11
13
|
|
|
12
14
|
Config precedence for start: env (BRUST_ADDR, BRUST_PORT, BRUST_WORKERS, BRUST_RENDER_SLOTS,
|
|
@@ -29,7 +31,10 @@ function intFlag(name: string, v: string | undefined, min: number): number | und
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export async function main(argv: string[]): Promise<void> {
|
|
32
|
-
const [cmd, ...
|
|
34
|
+
const [cmd, ...all] = argv
|
|
35
|
+
const dd = cmd === 'dev' ? all.indexOf('--') : -1
|
|
36
|
+
const rest = dd < 0 ? all : all.slice(0, dd)
|
|
37
|
+
const passthrough = dd < 0 ? [] : all.slice(dd + 1)
|
|
33
38
|
if (cmd === undefined || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
34
39
|
process.stdout.write(USAGE)
|
|
35
40
|
process.exit(cmd === undefined ? 2 : 0)
|
|
@@ -46,6 +51,8 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
46
51
|
port: { type: 'string' },
|
|
47
52
|
workers: { type: 'string' },
|
|
48
53
|
force: { type: 'boolean' },
|
|
54
|
+
target: { type: 'string' },
|
|
55
|
+
'wasm-from-source': { type: 'boolean' },
|
|
49
56
|
help: { type: 'boolean', short: 'h' },
|
|
50
57
|
},
|
|
51
58
|
})
|
|
@@ -58,6 +65,8 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
58
65
|
process.exit(0)
|
|
59
66
|
}
|
|
60
67
|
const str = (k: string) => values[k] as string | undefined
|
|
68
|
+
const target = str('target')
|
|
69
|
+
if (target !== undefined && target !== 'bun' && target !== 'cloudflare') usage(`--target must be bun or cloudflare (got ${JSON.stringify(target)})`)
|
|
61
70
|
|
|
62
71
|
if (cmd === 'build') {
|
|
63
72
|
if (positionals.length > 1) usage(`build takes one entry (got ${positionals.join(' ')})`)
|
|
@@ -68,6 +77,8 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
68
77
|
entry: positionals[0] ?? 'routes.tsx',
|
|
69
78
|
outDir: str('out-dir') ?? 'dist',
|
|
70
79
|
force: values.force === true,
|
|
80
|
+
target: target as 'bun' | 'cloudflare' | undefined,
|
|
81
|
+
wasmFromSource: values['wasm-from-source'] === true,
|
|
71
82
|
log: (s) => console.log(s),
|
|
72
83
|
})
|
|
73
84
|
} catch (e) {
|
|
@@ -79,6 +90,28 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
79
90
|
console.log(`[brust] built ${str('out-dir') ?? 'dist'} in ${Math.round(performance.now() - t0)}ms`)
|
|
80
91
|
process.exit(0)
|
|
81
92
|
}
|
|
93
|
+
if (cmd === 'dev') {
|
|
94
|
+
if (positionals.length > 1) usage(`dev takes one entry (got ${positionals.join(' ')})`)
|
|
95
|
+
if (target !== 'cloudflare') usage('brust dev: only --target cloudflare in this release')
|
|
96
|
+
const port = intFlag('port', str('port'), 0)
|
|
97
|
+
try {
|
|
98
|
+
process.exit(
|
|
99
|
+
await runDev({
|
|
100
|
+
appRoot: process.cwd(),
|
|
101
|
+
entry: positionals[0] ?? 'routes.tsx',
|
|
102
|
+
outDir: str('out-dir') ?? 'dist',
|
|
103
|
+
...(port !== undefined && { port }),
|
|
104
|
+
wranglerArgs: passthrough,
|
|
105
|
+
fromSource: values['wasm-from-source'] === true,
|
|
106
|
+
log: (s) => console.log(s),
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e instanceof BuildError) console.error(`error ${e.rule} ${e.message}`)
|
|
111
|
+
else console.error(`error dev ${(e as Error).stack ?? String(e)}`)
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
82
115
|
if (cmd === 'start') {
|
|
83
116
|
if (positionals.length > 0) usage(`start takes no positional arguments (got ${positionals.join(' ')})`)
|
|
84
117
|
const port = intFlag('port', str('port'), 0)
|
package/src/config.ts
CHANGED
|
@@ -89,6 +89,22 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** `[cors]` of brust.toml and nothing else (no env, no cli): what the Cloudflare build ships in CONFIG.
|
|
93
|
+
* Throws `BrustConfigError` on a bad table, exactly like `loadConfig`. */
|
|
94
|
+
export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
|
|
95
|
+
const tomlPath = join(cwd, 'brust.toml')
|
|
96
|
+
const file = Bun.file(tomlPath)
|
|
97
|
+
if (!(await file.exists())) return undefined
|
|
98
|
+
let parsed: unknown
|
|
99
|
+
try {
|
|
100
|
+
parsed = Bun.TOML.parse(await file.text())
|
|
101
|
+
} catch (e) {
|
|
102
|
+
throw new BrustConfigError(`failed to parse ${tomlPath}: ${(e as Error).message}`, tomlPath)
|
|
103
|
+
}
|
|
104
|
+
const root = (parsed ?? {}) as Record<string, unknown>
|
|
105
|
+
return 'cors' in root ? parseCors(root.cors, tomlPath) : undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
/** CLI / programmatic overrides get the same bounds as env (a CLI `--port 0` = any free port). */
|
|
93
109
|
function checkCli(cli: Partial<BrustConfig>): void {
|
|
94
110
|
const check = (name: string, v: number | undefined, min: number, max: number) => {
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `brust dev [entry] --target cloudflare [-- <wrangler args>]` (M4-CF R10): build, run `wrangler dev` on the
|
|
2
|
+
// generated project, rebuild + restart wrangler on a source change. No HMR. Each build runs in a child
|
|
3
|
+
// process: a failed build keeps the old wrangler, and module caches never leak between builds.
|
|
4
|
+
import { type FSWatcher, watch } from 'node:fs'
|
|
5
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
6
|
+
import { BuildError } from './build/errors'
|
|
7
|
+
|
|
8
|
+
export interface DevOptions {
|
|
9
|
+
appRoot: string
|
|
10
|
+
entry: string
|
|
11
|
+
outDir: string
|
|
12
|
+
port?: number
|
|
13
|
+
wranglerArgs: string[]
|
|
14
|
+
fromSource: boolean
|
|
15
|
+
log: (s: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const IGNORED = new Set(['node_modules', '.wrangler', '.git'])
|
|
19
|
+
const DEBOUNCE_MS = 300
|
|
20
|
+
|
|
21
|
+
function wranglerBin(appRoot: string): string {
|
|
22
|
+
const fake = process.env.BRUST_WRANGLER
|
|
23
|
+
if (fake) return fake
|
|
24
|
+
const found = Bun.which('wrangler', { PATH: `${join(appRoot, 'node_modules', '.bin')}:${process.env.PATH ?? ''}` })
|
|
25
|
+
if (!found) throw new BuildError('cf-dev', 'wrangler not found (bun add -d wrangler)')
|
|
26
|
+
return found
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolves with the exit code once the session ends (SIGINT/SIGTERM -> 0, or the child's own exit code). */
|
|
30
|
+
export async function runDev(o: DevOptions): Promise<number> {
|
|
31
|
+
const appRoot = resolve(o.appRoot)
|
|
32
|
+
const dist = resolve(appRoot, o.outDir)
|
|
33
|
+
const bin = wranglerBin(appRoot)
|
|
34
|
+
const build = (): Promise<number> => {
|
|
35
|
+
const p = Bun.spawn([process.execPath, join(import.meta.dir, 'cli.ts'), 'build', o.entry, '--target', 'cloudflare', '--out-dir', o.outDir, ...(o.fromSource ? ['--wasm-from-source'] : [])], {
|
|
36
|
+
cwd: appRoot,
|
|
37
|
+
stdout: 'inherit',
|
|
38
|
+
stderr: 'inherit',
|
|
39
|
+
})
|
|
40
|
+
return p.exited
|
|
41
|
+
}
|
|
42
|
+
if ((await build()) !== 0) return 1
|
|
43
|
+
|
|
44
|
+
const args = ['dev', '-c', join(dist, 'cf', 'wrangler.jsonc'), '--persist-to', join(appRoot, '.wrangler', 'state'), ...(o.port !== undefined ? ['--port', String(o.port)] : []), ...o.wranglerArgs]
|
|
45
|
+
let child: ReturnType<typeof Bun.spawn> | null = null
|
|
46
|
+
let stopping = false
|
|
47
|
+
let restarting = false
|
|
48
|
+
let exitCode: (c: number) => void = () => {}
|
|
49
|
+
const done = new Promise<number>((r) => {
|
|
50
|
+
exitCode = r
|
|
51
|
+
})
|
|
52
|
+
const spawn = () => {
|
|
53
|
+
const c = Bun.spawn([bin, ...args], { cwd: appRoot, stdio: ['inherit', 'inherit', 'inherit'] })
|
|
54
|
+
child = c
|
|
55
|
+
c.exited.then((code) => {
|
|
56
|
+
if (child !== c || stopping || restarting) return
|
|
57
|
+
watcher?.close()
|
|
58
|
+
exitCode(code)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
const stopChild = async () => {
|
|
62
|
+
const c = child
|
|
63
|
+
if (!c) return
|
|
64
|
+
c.kill('SIGTERM')
|
|
65
|
+
await c.exited
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
69
|
+
let building = false
|
|
70
|
+
let again = false
|
|
71
|
+
const rebuild = async () => {
|
|
72
|
+
if (building) {
|
|
73
|
+
again = true
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
building = true
|
|
77
|
+
do {
|
|
78
|
+
again = false
|
|
79
|
+
o.log('[brust] change detected; rebuilding')
|
|
80
|
+
if ((await build()) === 0) {
|
|
81
|
+
restarting = true
|
|
82
|
+
await stopChild()
|
|
83
|
+
restarting = false
|
|
84
|
+
if (!stopping) spawn()
|
|
85
|
+
} else o.log('[brust] rebuild failed; keeping the running wrangler')
|
|
86
|
+
} while (again && !stopping)
|
|
87
|
+
building = false
|
|
88
|
+
}
|
|
89
|
+
const ignored = (rel: string) => {
|
|
90
|
+
const abs = resolve(appRoot, rel)
|
|
91
|
+
return rel.split(sep).some((s) => IGNORED.has(s)) || abs === dist || abs.startsWith(dist + sep) || abs.startsWith(`${dist}.`) || abs.startsWith(join(dirname(dist), `.${basename(dist)}.brust-`)) || relative(dist, abs) === ''
|
|
92
|
+
}
|
|
93
|
+
let watcher: FSWatcher | null = watch(appRoot, { recursive: true }, (_ev, name) => {
|
|
94
|
+
if (!name || ignored(String(name))) return
|
|
95
|
+
clearTimeout(timer)
|
|
96
|
+
timer = setTimeout(() => void rebuild(), DEBOUNCE_MS)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
spawn()
|
|
100
|
+
const stop = () => {
|
|
101
|
+
if (stopping) return
|
|
102
|
+
stopping = true
|
|
103
|
+
clearTimeout(timer)
|
|
104
|
+
watcher?.close()
|
|
105
|
+
void stopChild().then(() => exitCode(0))
|
|
106
|
+
}
|
|
107
|
+
process.on('SIGINT', stop)
|
|
108
|
+
process.on('SIGTERM', stop)
|
|
109
|
+
return done
|
|
110
|
+
}
|