@astrale-os/adapter-cloudflare 0.5.0-beta.12 → 0.5.0-beta.13

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.
@@ -1,23 +1,33 @@
1
1
  import type { CompiledFrontendArtifact, DomainDefinition, FrontendFile } from '@astrale-os/sdk'
2
- import type { ReactFrontendArtifact } from '@astrale-os/sdk/react'
2
+ import type { ChildProcess } from 'node:child_process'
3
3
 
4
4
  import { compileFrontend } from '@astrale-os/sdk'
5
- import { compileReactEntrypoint } from '@astrale-os/sdk/react'
6
5
  import { spawn } from 'node:child_process'
7
- import { watch } from 'node:fs'
6
+ import { existsSync } from 'node:fs'
8
7
  import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
8
+ import { createServer } from 'node:net'
9
9
  import { dirname, join, resolve, sep } from 'node:path'
10
10
 
11
11
  export const FRONTEND_STAGE_DIR = '.astrale/frontend'
12
- const FRONTEND_REBUILD_DELAY_MS = 75
12
+ const FRONTEND_BUILD_DIR = '.astrale/frontend-build'
13
13
  let stageGeneration = 0
14
14
  const buildQueues = new Map<string, Promise<void>>()
15
15
 
16
+ export interface ViteBuildInput {
17
+ readonly projectDir: string
18
+ readonly output: string
19
+ readonly config?: string
20
+ readonly onLog?: (line: string) => void
21
+ }
22
+
23
+ export type ViteBuild = (input: ViteBuildInput) => Promise<void>
24
+
16
25
  /** Materialize one Domain-owned frontend recipe into an immutable adapter artifact. */
17
26
  export async function buildFrontend(input: {
18
27
  readonly definition: DomainDefinition
19
28
  readonly projectDir: string
20
29
  readonly onLog?: (line: string) => void
30
+ readonly buildVite?: ViteBuild
21
31
  }): Promise<CompiledFrontendArtifact | undefined> {
22
32
  const key = resolve(input.projectDir)
23
33
  const previous = buildQueues.get(key) ?? Promise.resolve()
@@ -40,6 +50,7 @@ async function buildFrontendUnlocked(input: {
40
50
  readonly definition: DomainDefinition
41
51
  readonly projectDir: string
42
52
  readonly onLog?: (line: string) => void
53
+ readonly buildVite?: ViteBuild
43
54
  }): Promise<CompiledFrontendArtifact | undefined> {
44
55
  const artifact = input.definition.frontend
45
56
  if (artifact === undefined) return undefined
@@ -50,44 +61,15 @@ async function buildFrontendUnlocked(input: {
50
61
  provenance: { adapter: 'cloudflare', source: 'external' },
51
62
  })
52
63
  }
53
-
54
- let files: readonly FrontendFile[]
55
- if (artifact.source.kind === 'generated') {
56
- files = artifact.source.files.map((file) => ({
57
- path: file.path,
58
- content:
59
- typeof file.content === 'string' ? new TextEncoder().encode(file.content) : file.content,
60
- ...(file.mediaType === undefined ? {} : { mediaType: file.mediaType }),
61
- }))
62
- } else if (artifact.source.kind === 'prebuilt') {
63
- files = await readFiles(
64
- contained(input.projectDir, artifact.source.directory, 'prebuilt frontend'),
65
- )
66
- } else {
67
- if (isReactFrontend(artifact)) {
68
- const root = contained(input.projectDir, artifact.source.root, 'React frontend root')
69
- for (const file of compileReactEntrypoint(artifact)) {
70
- const path = contained(root, file.path, 'generated React entrypoint')
71
- await mkdir(dirname(path), { recursive: true })
72
- await writeFile(path, file.content)
73
- }
74
- }
75
- const output = contained(input.projectDir, artifact.source.outDir, 'Vite frontend output')
76
- await rm(output, { recursive: true, force: true })
77
- await runBuild(
78
- artifact.source.command[0],
79
- [
80
- ...artifact.source.command.slice(1),
81
- artifact.source.root,
82
- '--outDir',
83
- output,
84
- '--emptyOutDir',
85
- ],
86
- input.projectDir,
87
- input.onLog,
88
- )
89
- files = await readFiles(output)
90
- }
64
+ const output = contained(input.projectDir, FRONTEND_BUILD_DIR, 'Vite frontend staging output')
65
+ await rm(output, { recursive: true, force: true })
66
+ await (input.buildVite ?? runViteBuild)({
67
+ projectDir: input.projectDir,
68
+ output,
69
+ ...(artifact.source.config === undefined ? {} : { config: artifact.source.config }),
70
+ ...(input.onLog === undefined ? {} : { onLog: input.onLog }),
71
+ })
72
+ const files = await readFiles(output)
91
73
  const compiled = await compileFrontend({
92
74
  artifact,
93
75
  baseHref: 'https://frontend.invalid/',
@@ -98,109 +80,53 @@ async function buildFrontendUnlocked(input: {
98
80
  return compiled
99
81
  }
100
82
 
101
- export interface FrontendWatchHandle {
83
+ export interface FrontendDevHandle {
84
+ readonly url: string
102
85
  stop(): Promise<void>
103
86
  }
104
87
 
105
- export interface FrontendWatchSubscription {
106
- close(): void
107
- }
108
-
109
- export type FrontendWatchSubscribe = (
110
- root: string,
111
- onChange: (path?: string) => void,
112
- onError: (error: Error) => void,
113
- ) => FrontendWatchSubscription
114
-
115
- /** Keep Wrangler's materialized frontend stage current without owning config regeneration. */
116
- export function watchFrontend(input: {
88
+ /** Start the Domain project's own Vite server; the Worker remains the browser-facing origin. */
89
+ export async function startFrontendDev(input: {
117
90
  readonly definition: DomainDefinition
118
91
  readonly projectDir: string
119
92
  readonly onLog?: (line: string) => void
120
- readonly onRebuild?: () => void
121
- readonly delayMs?: number
122
- readonly build?: typeof buildFrontend
123
- readonly subscribe?: FrontendWatchSubscribe
124
- }): FrontendWatchHandle | undefined {
93
+ }): Promise<FrontendDevHandle | undefined> {
125
94
  const artifact = input.definition.frontend
126
- if (
127
- artifact === undefined ||
128
- artifact.source.kind === 'external' ||
129
- artifact.source.kind === 'generated'
130
- )
131
- return undefined
132
-
133
- const source = artifact.source
134
- const root =
135
- source.kind === 'prebuilt'
136
- ? contained(input.projectDir, source.directory, 'prebuilt frontend watch root')
137
- : resolve(input.projectDir)
138
- const build = input.build ?? buildFrontend
139
- const subscribe = input.subscribe ?? subscribeToFrontend
140
- const delayMs = input.delayMs ?? FRONTEND_REBUILD_DELAY_MS
141
- let stopped = false
142
- let pending = false
143
- let timer: ReturnType<typeof setTimeout> | undefined
144
- let running: Promise<void> | undefined
145
-
146
- const drain = async (): Promise<void> => {
147
- if (stopped || running !== undefined || !pending) return
148
- pending = false
149
- running = (async () => {
150
- try {
151
- await build({
152
- definition: input.definition,
153
- projectDir: input.projectDir,
154
- onLog: input.onLog,
155
- })
156
- if (!stopped) input.onRebuild?.()
157
- } catch (error) {
158
- input.onLog?.(
159
- `frontend rebuild failed; keeping the last good stage: ${error instanceof Error ? error.message : String(error)}`,
160
- )
161
- }
162
- })()
163
- await running
164
- running = undefined
165
- if (!stopped && pending) {
166
- timer = setTimeout(() => {
167
- timer = undefined
168
- void drain()
169
- }, delayMs)
95
+ if (artifact === undefined || artifact.source.kind !== 'vite') return undefined
96
+ const command = viteCommand(input.projectDir)
97
+ const port = await ephemeralPort('127.0.0.1')
98
+ const args = [
99
+ ...command.prefix,
100
+ '--host',
101
+ '127.0.0.1',
102
+ '--port',
103
+ String(port),
104
+ '--strictPort',
105
+ ...(artifact.source.config === undefined ? [] : ['--config', artifact.source.config]),
106
+ ]
107
+ const child = spawn(command.command, args, {
108
+ cwd: input.projectDir,
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ })
111
+ const output: string[] = []
112
+ const collect = (buffer: Buffer) => {
113
+ for (const line of buffer.toString().split('\n')) {
114
+ if (line.trim() === '') continue
115
+ output.push(line)
116
+ if (output.length > 40) output.shift()
117
+ input.onLog?.(line)
170
118
  }
171
119
  }
172
- const schedule = (path?: string) => {
173
- if (stopped || !rebuildsFor(source, path)) return
174
- pending = true
175
- if (timer !== undefined) clearTimeout(timer)
176
- timer = setTimeout(() => {
177
- timer = undefined
178
- void drain()
179
- }, delayMs)
120
+ child.stdout?.on('data', collect)
121
+ child.stderr?.on('data', collect)
122
+ const url = `http://127.0.0.1:${port}`
123
+ try {
124
+ await waitUntilReady(url, child, output)
125
+ } catch (error) {
126
+ await stopChild(child)
127
+ throw error
180
128
  }
181
- const subscription = subscribe(root, schedule, (error) => {
182
- input.onLog?.(`frontend watcher failed: ${error.message}`)
183
- })
184
-
185
- return Object.freeze({
186
- async stop() {
187
- if (stopped) return
188
- stopped = true
189
- pending = false
190
- if (timer !== undefined) clearTimeout(timer)
191
- timer = undefined
192
- subscription.close()
193
- await running
194
- },
195
- })
196
- }
197
-
198
- function isReactFrontend(
199
- artifact: DomainDefinition['frontend'],
200
- ): artifact is ReactFrontendArtifact {
201
- return (
202
- artifact !== undefined && (artifact as { readonly framework?: unknown }).framework === 'react'
203
- )
129
+ return Object.freeze({ url, stop: () => stopChild(child) })
204
130
  }
205
131
 
206
132
  async function readFiles(root: string, current = ''): Promise<readonly FrontendFile[]> {
@@ -249,42 +175,6 @@ async function replaceStage(projectDir: string, files: readonly FrontendFile[]):
249
175
  }
250
176
  }
251
177
 
252
- function subscribeToFrontend(
253
- root: string,
254
- onChange: (path?: string) => void,
255
- onError: (error: Error) => void,
256
- ): FrontendWatchSubscription {
257
- const watcher = watch(root, { recursive: true }, (_event, path) => {
258
- onChange(path === null ? undefined : String(path))
259
- })
260
- watcher.on('error', onError)
261
- return watcher
262
- }
263
-
264
- function rebuildsFor(
265
- source: Exclude<
266
- NonNullable<DomainDefinition['frontend']>['source'],
267
- { readonly kind: 'external' | 'generated' }
268
- >,
269
- path: string | undefined,
270
- ): boolean {
271
- if (path === undefined || source.kind === 'prebuilt') return true
272
- const normalized = path.replaceAll('\\', '/')
273
- if (
274
- normalized === '.astrale' ||
275
- normalized.startsWith('.astrale/') ||
276
- normalized === 'node_modules' ||
277
- normalized.startsWith('node_modules/') ||
278
- normalized === '.git' ||
279
- normalized.startsWith('.git/')
280
- )
281
- return false
282
- const outDir = source.outDir.replace(/^\.\//u, '')
283
- if (normalized === outDir || normalized.startsWith(`${outDir}/`)) return false
284
- const root = source.root.replace(/^\.\//u, '')
285
- return normalized !== `${root}/astrale.generated.tsx` && normalized !== `${root}/index.html`
286
- }
287
-
288
178
  function isMissing(error: unknown): boolean {
289
179
  return error instanceof Error && 'code' in error && error.code === 'ENOENT'
290
180
  }
@@ -298,7 +188,34 @@ function contained(root: string, input: string, label: string): string {
298
188
  return target
299
189
  }
300
190
 
301
- function runBuild(
191
+ function viteCommand(projectDir: string): { command: string; prefix: readonly string[] } {
192
+ const command = join(projectDir, 'node_modules', '.bin', 'vite')
193
+ if (!existsSync(command)) {
194
+ throw new TypeError(
195
+ 'Vite frontend requires the Domain project to install vite as a development dependency.',
196
+ )
197
+ }
198
+ return { command, prefix: [] }
199
+ }
200
+
201
+ async function runViteBuild(input: ViteBuildInput): Promise<void> {
202
+ const command = viteCommand(input.projectDir)
203
+ await runProcess(
204
+ command.command,
205
+ [
206
+ ...command.prefix,
207
+ 'build',
208
+ '--outDir',
209
+ input.output,
210
+ '--emptyOutDir',
211
+ ...(input.config === undefined ? [] : ['--config', input.config]),
212
+ ],
213
+ input.projectDir,
214
+ input.onLog,
215
+ )
216
+ }
217
+
218
+ function runProcess(
302
219
  command: string,
303
220
  args: readonly string[],
304
221
  cwd: string,
@@ -318,7 +235,64 @@ function runBuild(
318
235
  child.on('exit', (code) =>
319
236
  code === 0
320
237
  ? resolveBuild()
321
- : reject(new Error(`frontend build failed (code ${code ?? 1}):\n${output.slice(-1500)}`)),
238
+ : reject(
239
+ new Error(`Vite frontend build failed (code ${code ?? 1}):\n${output.slice(-1500)}`),
240
+ ),
322
241
  )
323
242
  })
324
243
  }
244
+
245
+ function ephemeralPort(host: string): Promise<number> {
246
+ return new Promise((resolvePort, reject) => {
247
+ const server = createServer()
248
+ server.once('error', reject)
249
+ server.listen(0, host, () => {
250
+ const address = server.address()
251
+ const port = typeof address === 'object' && address !== null ? address.port : 0
252
+ server.close((error) => (error === undefined ? resolvePort(port) : reject(error)))
253
+ })
254
+ })
255
+ }
256
+
257
+ async function waitUntilReady(
258
+ url: string,
259
+ child: ChildProcess,
260
+ output: readonly string[],
261
+ ): Promise<void> {
262
+ const deadline = Date.now() + 15_000
263
+ while (Date.now() < deadline) {
264
+ if (child.exitCode !== null) {
265
+ throw new Error(
266
+ `Vite frontend dev server exited before becoming ready (code ${child.exitCode}).\n${output.join('\n')}`,
267
+ )
268
+ }
269
+ try {
270
+ const response = await fetch(url, { signal: AbortSignal.timeout(500) })
271
+ await response.body?.cancel()
272
+ return
273
+ } catch {
274
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 50))
275
+ }
276
+ }
277
+ throw new Error(`Vite frontend dev server did not become ready at ${url}.\n${output.join('\n')}`)
278
+ }
279
+
280
+ function stopChild(child: ChildProcess): Promise<void> {
281
+ if (child.exitCode !== null || child.killed) return Promise.resolve()
282
+ return new Promise((resolveStop) => {
283
+ let settled = false
284
+ const finish = () => {
285
+ if (settled) return
286
+ settled = true
287
+ clearTimeout(timer)
288
+ resolveStop()
289
+ }
290
+ const timer = setTimeout(() => {
291
+ if (child.exitCode === null) child.kill('SIGKILL')
292
+ finish()
293
+ }, 2_000)
294
+ timer.unref?.()
295
+ child.once('exit', finish)
296
+ child.kill('SIGTERM')
297
+ })
298
+ }
@@ -13,10 +13,10 @@
13
13
  export {
14
14
  buildFrontend,
15
15
  FRONTEND_STAGE_DIR,
16
- watchFrontend,
17
- type FrontendWatchHandle,
18
- type FrontendWatchSubscribe,
19
- type FrontendWatchSubscription,
16
+ startFrontendDev,
17
+ type FrontendDevHandle,
18
+ type ViteBuild,
19
+ type ViteBuildInput,
20
20
  } from './frontend.js'
21
21
  export { logTo, prepare } from './prepare.js'
22
22
  export { buildCloudflareWorker, type CloudflareWorkerBuild } from './worker.js'
@@ -17,6 +17,7 @@ import { generateWranglerConfig } from './codegen/wrangler.js'
17
17
 
18
18
  const DEFAULT_PORT = 8787
19
19
  const DEPLOYMENT_VERSION_BINDING = 'ASTRALE_DEPLOYMENT_VERSION'
20
+ const FRONTEND_DEV_BINDING = 'FRONTEND_DEV_URL'
20
21
  const WORKER_NAME_RE = /^[a-z0-9][a-z0-9-]*$/
21
22
 
22
23
  /** Prepare immutable generated Worker sources and Wrangler configuration. */
@@ -25,12 +26,23 @@ export async function prepare(
25
26
  ctx: WatchCtx | DeployCtx | BuildCtx,
26
27
  mode: 'build' | 'dev' | 'deploy',
27
28
  frontend?: CompiledFrontendArtifact,
29
+ frontendDevUrl?: string,
28
30
  ): Promise<{ configPath: string; fallbackConfigPath?: string; port: number; workerName: string }> {
29
31
  if (Object.hasOwn(params.vars ?? {}, DEPLOYMENT_VERSION_BINDING)) {
30
32
  throw new Error(
31
33
  `cloudflare adapter: \`${DEPLOYMENT_VERSION_BINDING}\` is adapter-managed and cannot be set in \`vars\`.`,
32
34
  )
33
35
  }
36
+ if (Object.hasOwn(params.vars ?? {}, FRONTEND_DEV_BINDING)) {
37
+ throw new Error(
38
+ `cloudflare adapter: \`${FRONTEND_DEV_BINDING}\` is adapter-managed and cannot be set in \`vars\`.`,
39
+ )
40
+ }
41
+ if (frontendDevUrl !== undefined && (mode !== 'dev' || !localFrontendDefinition(ctx))) {
42
+ throw new TypeError(
43
+ 'cloudflare adapter: frontendDevUrl requires a local Vite frontend in dev mode.',
44
+ )
45
+ }
34
46
  const astraleDir = join(ctx.projectDir, '.astrale')
35
47
  await mkdir(astraleDir, { recursive: true })
36
48
  await ensureIdentity(astraleDir, ctx.definition.schema.origin)
@@ -39,9 +51,14 @@ export async function prepare(
39
51
  const router = resolveRouter(params.router)
40
52
  const localFrontend =
41
53
  ctx.definition.frontend !== undefined && ctx.definition.frontend.source.kind !== 'external'
42
- if (localFrontend !== (frontend !== undefined && frontend.artifact.source.kind !== 'external')) {
54
+ const developmentFrontend = mode === 'dev' && frontendDevUrl !== undefined
55
+ if (
56
+ !developmentFrontend &&
57
+ localFrontend !== (frontend !== undefined && frontend.artifact.source.kind !== 'external')
58
+ ) {
43
59
  throw new TypeError('cloudflare adapter: materialized frontend must match definition.frontend.')
44
60
  }
61
+ if (developmentFrontend) await mkdir(join(astraleDir, 'frontend'), { recursive: true })
45
62
 
46
63
  await writeFileAtomic(
47
64
  join(astraleDir, 'worker.gen.ts'),
@@ -67,6 +84,7 @@ export async function prepare(
67
84
  : undefined
68
85
  const vars = {
69
86
  ...(workerUrl ? { WORKER_URL: workerUrl } : {}),
87
+ ...(frontendDevUrl === undefined ? {} : { FRONTEND_DEV_URL: frontendDevUrl }),
70
88
  ...(mode === 'dev'
71
89
  ? { ...params.vars, ...('secrets' in ctx ? ctx.secrets : {}) }
72
90
  : { ...params.vars }),
@@ -79,6 +97,7 @@ export async function prepare(
79
97
  frontend: {
80
98
  directory: './frontend',
81
99
  routes: localFrontendRoutes(ctx.definition),
100
+ dev: frontendDevUrl !== undefined,
82
101
  },
83
102
  }
84
103
  : {}),
@@ -112,11 +131,20 @@ function localFrontendRoutes(
112
131
  ): readonly string[] {
113
132
  const frontend = definition.frontend
114
133
  if (frontend === undefined || frontend.source.kind === 'external') return Object.freeze([])
115
- return Object.freeze(
116
- Object.values(frontend.routes as Readonly<Record<string, FrontendRoute>>)
117
- .flatMap((route) => (route.path === undefined ? [] : [route.path]))
118
- .sort(),
119
- )
134
+ const routes = Object.values(frontend.routes as Readonly<Record<string, FrontendRoute>>)
135
+ .flatMap((route) =>
136
+ frontend.source.kind === 'vite' && frontend.source.spa && route.path !== '/'
137
+ ? [route.path, `${route.path}/*`]
138
+ : route.path === '/'
139
+ ? ['/*']
140
+ : [route.path],
141
+ )
142
+ .sort()
143
+ return Object.freeze(routes)
144
+ }
145
+
146
+ function localFrontendDefinition(ctx: WatchCtx | DeployCtx | BuildCtx): boolean {
147
+ return ctx.definition.frontend?.source.kind === 'vite'
120
148
  }
121
149
 
122
150
  async function writeFileAtomic(path: string, content: string): Promise<void> {
package/src/cloudflare.ts CHANGED
@@ -14,6 +14,7 @@ import type { DeployFetch } from '@astrale-os/sdk/deployment'
14
14
 
15
15
  import { defineAdapter } from '@astrale-os/sdk'
16
16
  import { deployCheck } from '@astrale-os/sdk/deployment'
17
+ import { resolve } from 'node:path'
17
18
 
18
19
  import type { CloudflareParams } from './params.js'
19
20
 
@@ -26,7 +27,7 @@ import {
26
27
  runWranglerDeploy,
27
28
  runWranglerDev,
28
29
  runWranglerTypes,
29
- watchFrontend,
30
+ startFrontendDev,
30
31
  } from './build/index.js'
31
32
 
32
33
  export function cloudflare(
@@ -41,7 +42,7 @@ export function cloudflare(
41
42
  runWranglerDeploy,
42
43
  runWranglerDev,
43
44
  runWranglerTypes,
44
- watchFrontend,
45
+ startFrontendDev,
45
46
  waitUntilLive,
46
47
  })
47
48
  }
@@ -55,7 +56,7 @@ interface CloudflareAdapterDependencies {
55
56
  readonly runWranglerDeploy: typeof runWranglerDeploy
56
57
  readonly runWranglerDev: typeof runWranglerDev
57
58
  readonly runWranglerTypes: typeof runWranglerTypes
58
- readonly watchFrontend: typeof watchFrontend
59
+ readonly startFrontendDev: typeof startFrontendDev
59
60
  readonly waitUntilLive: typeof waitUntilLive
60
61
  }
61
62
 
@@ -64,6 +65,7 @@ export function cloudflareWithDependencies(
64
65
  envs: Record<string, CloudflareParams>,
65
66
  dependencies: CloudflareAdapterDependencies,
66
67
  ): DomainAdapter<CloudflareParams> {
68
+ const frontendDevUrls = new Map<string, string>()
67
69
  return defineAdapter<CloudflareParams>({
68
70
  name: 'cloudflare',
69
71
  envs,
@@ -78,44 +80,46 @@ export function cloudflareWithDependencies(
78
80
 
79
81
  async watch(params, ctx) {
80
82
  const onLog = dependencies.logTo()
81
- const frontend = await dependencies.buildFrontend({
82
- definition: ctx.definition,
83
- projectDir: ctx.projectDir,
84
- onLog,
85
- })
86
- const { configPath, port } = await dependencies.prepare(params, ctx, 'dev', frontend)
87
- const handle = await dependencies.runWranglerDev({
88
- projectDir: ctx.projectDir,
89
- configPath,
90
- port,
91
- remote: Boolean(params.remote),
92
- autoPickPort: params.port === undefined,
93
- ...(params.host ? { ip: '0.0.0.0' } : {}),
94
- onReload: ctx.onReload,
95
- onLog,
96
- })
97
- let frontendWatch: ReturnType<typeof watchFrontend>
83
+ const projectKey = resolve(ctx.projectDir)
84
+ let frontendDev: Awaited<ReturnType<typeof startFrontendDev>>
85
+ let handle: Awaited<ReturnType<typeof runWranglerDev>> | undefined
98
86
  try {
99
- frontendWatch = dependencies.watchFrontend({
87
+ frontendDev = await dependencies.startFrontendDev({
100
88
  definition: ctx.definition,
101
89
  projectDir: ctx.projectDir,
102
90
  onLog,
103
- // Routes and source recipes are immutable for this watch generation.
104
- // Wrangler already observes the atomically swapped asset stage, so a
105
- // source rebuild needs no config regeneration.
106
- onRebuild: ctx.onReload,
91
+ })
92
+ if (frontendDev !== undefined) frontendDevUrls.set(projectKey, frontendDev.url)
93
+ const { configPath, port } = await dependencies.prepare(
94
+ params,
95
+ ctx,
96
+ 'dev',
97
+ undefined,
98
+ frontendDev?.url,
99
+ )
100
+ handle = await dependencies.runWranglerDev({
101
+ projectDir: ctx.projectDir,
102
+ configPath,
103
+ port,
104
+ remote: Boolean(params.remote),
105
+ autoPickPort: params.port === undefined,
106
+ ...(params.host ? { ip: '0.0.0.0' } : {}),
107
+ onReload: ctx.onReload,
108
+ onLog,
107
109
  })
108
110
  } catch (error) {
111
+ frontendDevUrls.delete(projectKey)
109
112
  try {
110
- await handle.stop()
113
+ await frontendDev?.stop()
111
114
  } catch (cleanupError) {
112
115
  throw new AggregateError(
113
116
  [error, cleanupError],
114
- 'cloudflare frontend watcher startup failed and Wrangler cleanup also failed',
117
+ 'cloudflare development startup failed and Vite cleanup also failed',
115
118
  )
116
119
  }
117
120
  throw error
118
121
  }
122
+ if (handle === undefined) throw new Error('cloudflare local Worker failed to start')
119
123
  // With --host the actionable URL is the PUBLIC one (the tunnel/proxy
120
124
  // front), not the local bind — print and spec-stamp that.
121
125
  return {
@@ -123,8 +127,9 @@ export function cloudflareWithDependencies(
123
127
  localUrl: handle.url,
124
128
  async stop() {
125
129
  const errors: unknown[] = []
130
+ frontendDevUrls.delete(projectKey)
126
131
  try {
127
- await frontendWatch?.stop()
132
+ await frontendDev?.stop()
128
133
  } catch (error) {
129
134
  errors.push(error)
130
135
  }
@@ -141,17 +146,16 @@ export function cloudflareWithDependencies(
141
146
  }
142
147
  },
143
148
 
144
- // Config hot-regen: rewrite the generated files only — the running
145
- // `wrangler dev` watches its `--config` (and the worker entry) and reloads
146
- // them itself, so a `astrale.config.ts` edit lands without a restart.
147
- // Mirrors `watch`'s prep exactly (codegen + frontend build), minus the spawn.
149
+ // Config hot-regen rewrites generated files only. Wrangler reloads its config and entry;
150
+ // the author-owned Vite process keeps its own source/config watch lifecycle.
148
151
  async regenerate(params, ctx) {
149
- const frontend = await dependencies.buildFrontend({
150
- definition: ctx.definition,
151
- projectDir: ctx.projectDir,
152
- onLog: dependencies.logTo(),
153
- })
154
- const { configPath } = await dependencies.prepare(params, ctx, 'dev', frontend)
152
+ const { configPath } = await dependencies.prepare(
153
+ params,
154
+ ctx,
155
+ 'dev',
156
+ undefined,
157
+ frontendDevUrls.get(resolve(ctx.projectDir)),
158
+ )
155
159
  await dependencies.runWranglerTypes({
156
160
  projectDir: ctx.projectDir,
157
161
  configPath,