@astrale-os/adapter-cloudflare 0.5.0-beta.11 → 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.
package/src/cloudflare.ts CHANGED
@@ -10,8 +10,11 @@
10
10
  */
11
11
 
12
12
  import type { DomainAdapter } from '@astrale-os/sdk'
13
+ import type { DeployFetch } from '@astrale-os/sdk/deployment'
13
14
 
14
15
  import { defineAdapter } from '@astrale-os/sdk'
16
+ import { deployCheck } from '@astrale-os/sdk/deployment'
17
+ import { resolve } from 'node:path'
15
18
 
16
19
  import type { CloudflareParams } from './params.js'
17
20
 
@@ -24,7 +27,7 @@ import {
24
27
  runWranglerDeploy,
25
28
  runWranglerDev,
26
29
  runWranglerTypes,
27
- watchFrontend,
30
+ startFrontendDev,
28
31
  } from './build/index.js'
29
32
 
30
33
  export function cloudflare(
@@ -39,7 +42,7 @@ export function cloudflare(
39
42
  runWranglerDeploy,
40
43
  runWranglerDev,
41
44
  runWranglerTypes,
42
- watchFrontend,
45
+ startFrontendDev,
43
46
  waitUntilLive,
44
47
  })
45
48
  }
@@ -53,7 +56,7 @@ interface CloudflareAdapterDependencies {
53
56
  readonly runWranglerDeploy: typeof runWranglerDeploy
54
57
  readonly runWranglerDev: typeof runWranglerDev
55
58
  readonly runWranglerTypes: typeof runWranglerTypes
56
- readonly watchFrontend: typeof watchFrontend
59
+ readonly startFrontendDev: typeof startFrontendDev
57
60
  readonly waitUntilLive: typeof waitUntilLive
58
61
  }
59
62
 
@@ -62,6 +65,7 @@ export function cloudflareWithDependencies(
62
65
  envs: Record<string, CloudflareParams>,
63
66
  dependencies: CloudflareAdapterDependencies,
64
67
  ): DomainAdapter<CloudflareParams> {
68
+ const frontendDevUrls = new Map<string, string>()
65
69
  return defineAdapter<CloudflareParams>({
66
70
  name: 'cloudflare',
67
71
  envs,
@@ -76,52 +80,56 @@ export function cloudflareWithDependencies(
76
80
 
77
81
  async watch(params, ctx) {
78
82
  const onLog = dependencies.logTo()
79
- const frontend = await dependencies.buildFrontend({
80
- definition: ctx.definition,
81
- projectDir: ctx.projectDir,
82
- onLog,
83
- })
84
- const { configPath, port } = await dependencies.prepare(params, ctx, 'dev', frontend)
85
- const handle = await dependencies.runWranglerDev({
86
- projectDir: ctx.projectDir,
87
- configPath,
88
- port,
89
- remote: Boolean(params.remote),
90
- autoPickPort: params.port === undefined,
91
- ...(params.host ? { ip: '0.0.0.0' } : {}),
92
- onReload: ctx.onReload,
93
- onLog,
94
- })
95
- 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
96
86
  try {
97
- frontendWatch = dependencies.watchFrontend({
87
+ frontendDev = await dependencies.startFrontendDev({
98
88
  definition: ctx.definition,
99
89
  projectDir: ctx.projectDir,
100
90
  onLog,
101
- // Routes and source recipes are immutable for this watch generation.
102
- // Wrangler already observes the atomically swapped asset stage, so a
103
- // source rebuild needs no config regeneration.
104
- 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,
105
109
  })
106
110
  } catch (error) {
111
+ frontendDevUrls.delete(projectKey)
107
112
  try {
108
- await handle.stop()
113
+ await frontendDev?.stop()
109
114
  } catch (cleanupError) {
110
115
  throw new AggregateError(
111
116
  [error, cleanupError],
112
- 'cloudflare frontend watcher startup failed and Wrangler cleanup also failed',
117
+ 'cloudflare development startup failed and Vite cleanup also failed',
113
118
  )
114
119
  }
115
120
  throw error
116
121
  }
122
+ if (handle === undefined) throw new Error('cloudflare local Worker failed to start')
117
123
  // With --host the actionable URL is the PUBLIC one (the tunnel/proxy
118
124
  // front), not the local bind — print and spec-stamp that.
119
125
  return {
120
126
  url: params.host ?? handle.url,
127
+ localUrl: handle.url,
121
128
  async stop() {
122
129
  const errors: unknown[] = []
130
+ frontendDevUrls.delete(projectKey)
123
131
  try {
124
- await frontendWatch?.stop()
132
+ await frontendDev?.stop()
125
133
  } catch (error) {
126
134
  errors.push(error)
127
135
  }
@@ -138,17 +146,16 @@ export function cloudflareWithDependencies(
138
146
  }
139
147
  },
140
148
 
141
- // Config hot-regen: rewrite the generated files only — the running
142
- // `wrangler dev` watches its `--config` (and the worker entry) and reloads
143
- // them itself, so a `astrale.config.ts` edit lands without a restart.
144
- // 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.
145
151
  async regenerate(params, ctx) {
146
- const frontend = await dependencies.buildFrontend({
147
- definition: ctx.definition,
148
- projectDir: ctx.projectDir,
149
- onLog: dependencies.logTo(),
150
- })
151
- 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
+ )
152
159
  await dependencies.runWranglerTypes({
153
160
  projectDir: ctx.projectDir,
154
161
  configPath,
@@ -187,7 +194,13 @@ export function cloudflareWithDependencies(
187
194
  // propagate; an `astrale domain install <url>` issued right away would
188
195
  // 404 (Cloudflare 1042). Block until the URL actually serves so the
189
196
  // install line we print is immediately actionable.
190
- await dependencies.waitUntilLive(url, dependencies.logTo(), deploymentVersion)
197
+ await dependencies.waitUntilLive(url, dependencies.logTo(), {
198
+ expectedOrigin: ctx.definition.schema.origin,
199
+ expectedRevision: ctx.definition.domain.$.revision,
200
+ ...(deploymentVersion === undefined
201
+ ? {}
202
+ : { expectedDeploymentVersion: deploymentVersion }),
203
+ })
191
204
  return { url }
192
205
  },
193
206
 
@@ -198,54 +211,65 @@ export function cloudflareWithDependencies(
198
211
  }
199
212
 
200
213
  /**
201
- * Poll until the deployed Worker health endpoint returns exactly 200.
214
+ * Poll until the deployed Worker exposes one complete canonical deployment chain.
202
215
  */
203
- async function waitUntilLive(
216
+ export async function waitUntilLive(
204
217
  url: string,
205
218
  onLog: (line: string) => void,
206
- expectedDeploymentVersion?: string,
207
- timeoutMs = 90_000,
219
+ expectations: {
220
+ readonly expectedOrigin: Parameters<typeof deployCheck>[0]['expectedOrigin']
221
+ readonly expectedRevision: Parameters<typeof deployCheck>[0]['expectedRevision']
222
+ readonly expectedDeploymentVersion?: string
223
+ readonly fetch?: DeployFetch
224
+ readonly timeoutMs?: number
225
+ readonly fetchTimeoutMs?: number
226
+ readonly retryMs?: number
227
+ readonly sleep?: (ms: number) => Promise<void>
228
+ },
208
229
  ): Promise<void> {
209
- const probe = new URL('/health', url)
210
- const metadata = new URL('/meta', url)
230
+ const timeoutMs = expectations.timeoutMs ?? 90_000
231
+ const fetchTimeoutMs = expectations.fetchTimeoutMs ?? 5_000
232
+ const retryMs = expectations.retryMs ?? 3_000
233
+ const sleep =
234
+ expectations.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
211
235
  const deadline = Date.now() + timeoutMs
212
236
  let waited = false
213
- let lastStatus: number | undefined
214
- let lastDeploymentVersion: string | undefined
237
+ let lastReason = 'not checked'
215
238
  for (;;) {
216
239
  try {
217
- const res = await fetch(probe, { redirect: 'manual' })
218
- lastStatus = res.status
219
- await res.body?.cancel()
220
- if (res.status === 200) {
221
- if (expectedDeploymentVersion === undefined) return
222
- const meta = await fetch(metadata, { redirect: 'manual' })
223
- if (meta.status === 200) {
224
- const body = (await meta.json()) as { deploymentVersion?: unknown }
225
- lastDeploymentVersion =
226
- typeof body.deploymentVersion === 'string' ? body.deploymentVersion : undefined
227
- if (lastDeploymentVersion === expectedDeploymentVersion) return
228
- } else {
229
- await meta.body?.cancel()
230
- }
240
+ const deployed = await deployCheck({
241
+ url,
242
+ expectedOrigin: expectations.expectedOrigin,
243
+ expectedRevision: expectations.expectedRevision,
244
+ signal: AbortSignal.timeout(fetchTimeoutMs),
245
+ ...(expectations.fetch === undefined ? {} : { fetch: expectations.fetch }),
246
+ log: () => undefined,
247
+ })
248
+ const actualDeploymentVersion = new URL(deployed.schema.bundle.href).searchParams.get(
249
+ 'deployment',
250
+ )
251
+ if (
252
+ expectations.expectedDeploymentVersion === undefined ||
253
+ actualDeploymentVersion === expectations.expectedDeploymentVersion
254
+ ) {
255
+ return
231
256
  }
232
- } catch {
233
- // Network hiccup — treat like "not live yet" and keep polling.
257
+ lastReason =
258
+ `bundle deployment=${JSON.stringify(actualDeploymentVersion)} ` +
259
+ `expected=${JSON.stringify(expectations.expectedDeploymentVersion)}`
260
+ } catch (error) {
261
+ lastReason = error instanceof Error ? error.message : String(error)
234
262
  }
235
263
  if (Date.now() >= deadline) {
236
264
  throw new Error(
237
- `deployed Worker health did not reach HTTP 200 within ${Math.round(timeoutMs / 1000)}s` +
238
- (lastStatus === undefined ? '' : ` (last status ${lastStatus})`) +
239
- (expectedDeploymentVersion === undefined
240
- ? ''
241
- : `; deployment version ${JSON.stringify(lastDeploymentVersion)} ` +
242
- `did not reach ${JSON.stringify(expectedDeploymentVersion)}`),
265
+ `deployed Worker did not expose a valid Publication chain within ` +
266
+ `${Math.round(timeoutMs / 1000)}s; last check: ${lastReason}`,
243
267
  )
244
268
  }
245
269
  if (!waited) {
246
270
  waited = true
247
271
  onLog('waiting for the URL to go live (first deploys can take ~30-60s)…')
248
272
  }
249
- await new Promise((r) => setTimeout(r, 3000))
273
+ await sleep(Math.min(retryMs, Math.max(0, deadline - Date.now())))
250
274
  }
251
275
  }
@@ -158,14 +158,6 @@ export function cloudflareWorkerEntry<
158
158
  const deploymentQuery = `deployment=${encodeURIComponent(version)}`
159
159
  const issuerId = issuer.accept(url)
160
160
  const host = new HonoApplication()
161
- host.get('/meta', (context) =>
162
- context.json({
163
- origin: definition.schema.origin,
164
- issuer: issuerId,
165
- schemaRevision: definition.domain.$.revision,
166
- deploymentVersion: version,
167
- }),
168
- )
169
161
  const serving: Application<Hono> = createRemoteApplication({
170
162
  definition,
171
163
  environment,
@@ -267,20 +259,35 @@ async function frontendAsset<S extends schema.DomainSchema>(
267
259
  ): Promise<Response | undefined> {
268
260
  const frontend = definition.frontend
269
261
  if (frontend === undefined || frontend.source.kind === 'external') return undefined
262
+ if (isReservedServicePath(url.pathname)) return undefined
263
+ if (request.method !== 'GET' && request.method !== 'HEAD') return undefined
264
+ const dev = environment.FRONTEND_DEV_URL?.replace(/\/+$/u, '')
265
+ if (dev !== undefined && dev.length > 0) {
266
+ return fetch(new Request(new URL(`${url.pathname}${url.search}`, `${dev}/`), request))
267
+ }
270
268
  const routes = frontend.routes as Readonly<Record<string, FrontendRoute>>
271
- const route = Object.values(routes).find((candidate) => candidate.path === url.pathname)
269
+ const spa = frontend.source.kind === 'vite' && frontend.source.spa
270
+ const route = Object.values(routes)
271
+ .filter(
272
+ (candidate) =>
273
+ candidate.path === url.pathname ||
274
+ (spa && (candidate.path === '/' || url.pathname.startsWith(`${candidate.path}/`))),
275
+ )
276
+ .sort((left, right) => right.path.length - left.path.length)[0]
272
277
  const staticAsset = url.pathname.startsWith('/assets/')
273
278
  if (route === undefined && !staticAsset) return undefined
274
- if (request.method !== 'GET' && request.method !== 'HEAD') return undefined
275
279
  const binding = environment.ASSETS
276
280
  if (binding === undefined) return undefined
277
- const dev = environment.FRONTEND_DEV_URL?.replace(/\/+$/u, '')
278
- if (dev !== undefined && dev.length > 0) {
279
- return fetch(new Request(`${dev}${url.pathname}${url.search}`, request))
280
- }
281
- const path =
282
- route === undefined ? `${url.pathname}${url.search}` : `/${route.document}${url.search}`
283
- return binding.fetch(new Request(new URL(path, url.origin), request))
281
+ const asset = await binding.fetch(request)
282
+ if (asset.status !== 404 || route === undefined) return asset
283
+ await asset.body?.cancel()
284
+ return binding.fetch(new Request(new URL(`/${route.document}${url.search}`, url.origin), request))
285
+ }
286
+
287
+ function isReservedServicePath(path: string): boolean {
288
+ return ['/invoke', '/install', '/health', '/meta', '/.well-known'].some(
289
+ (reserved) => path === reserved || path.startsWith(`${reserved}/`),
290
+ )
284
291
  }
285
292
 
286
293
  function requestOrigin(url: URL, request: Request): string {