@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.
@@ -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, ...rest] = argv
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
+ }