@astrale-os/adapter-cloudflare 0.4.10 → 0.5.0-beta.2

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.
Files changed (128) hide show
  1. package/dist/build/codegen/identity.d.ts.map +1 -0
  2. package/dist/build/codegen/identity.js.map +1 -0
  3. package/dist/build/codegen/merge.d.ts.map +1 -0
  4. package/dist/build/codegen/merge.js.map +1 -0
  5. package/dist/build/codegen/worker.d.ts +12 -0
  6. package/dist/build/codegen/worker.d.ts.map +1 -0
  7. package/dist/build/codegen/worker.js +40 -0
  8. package/dist/build/codegen/worker.js.map +1 -0
  9. package/dist/{codegen → build/codegen}/wrangler.d.ts +6 -3
  10. package/dist/build/codegen/wrangler.d.ts.map +1 -0
  11. package/dist/{codegen → build/codegen}/wrangler.js +29 -10
  12. package/dist/build/codegen/wrangler.js.map +1 -0
  13. package/dist/build/frontend.d.ts +26 -0
  14. package/dist/build/frontend.d.ts.map +1 -0
  15. package/dist/build/frontend.js +263 -0
  16. package/dist/build/frontend.js.map +1 -0
  17. package/dist/{build.js → build/index.d.ts} +5 -4
  18. package/dist/build/index.d.ts.map +1 -0
  19. package/dist/{build.d.ts → build/index.js} +5 -5
  20. package/dist/build/index.js.map +1 -0
  21. package/dist/{parse-output.d.ts → build/parse-output.d.ts} +2 -0
  22. package/dist/build/parse-output.d.ts.map +1 -0
  23. package/dist/{parse-output.js → build/parse-output.js} +5 -0
  24. package/dist/build/parse-output.js.map +1 -0
  25. package/dist/build/prepare.d.ts +18 -0
  26. package/dist/build/prepare.d.ts.map +1 -0
  27. package/dist/build/prepare.js +117 -0
  28. package/dist/build/prepare.js.map +1 -0
  29. package/dist/build/worker.d.ts +15 -0
  30. package/dist/build/worker.d.ts.map +1 -0
  31. package/dist/build/worker.js +21 -0
  32. package/dist/build/worker.js.map +1 -0
  33. package/dist/{wrangler-cli.d.ts → build/wrangler-cli.d.ts} +8 -0
  34. package/dist/build/wrangler-cli.d.ts.map +1 -0
  35. package/dist/{wrangler-cli.js → build/wrangler-cli.js} +28 -4
  36. package/dist/build/wrangler-cli.js.map +1 -0
  37. package/dist/cloudflare.d.ts +21 -24
  38. package/dist/cloudflare.d.ts.map +1 -1
  39. package/dist/cloudflare.js +125 -191
  40. package/dist/cloudflare.js.map +1 -1
  41. package/dist/index.d.ts +4 -4
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -3
  44. package/dist/index.js.map +1 -1
  45. package/dist/params.d.ts +13 -16
  46. package/dist/params.d.ts.map +1 -1
  47. package/dist/worker/index.d.ts +48 -0
  48. package/dist/worker/index.d.ts.map +1 -0
  49. package/dist/worker/index.js +186 -0
  50. package/dist/worker/index.js.map +1 -0
  51. package/package.json +13 -5
  52. package/src/build/codegen/worker.ts +53 -0
  53. package/src/{codegen → build/codegen}/wrangler.ts +34 -12
  54. package/src/build/frontend.ts +324 -0
  55. package/src/{build.ts → build/index.ts} +17 -4
  56. package/src/{parse-output.ts → build/parse-output.ts} +6 -0
  57. package/src/build/prepare.ts +161 -0
  58. package/src/build/worker.ts +37 -0
  59. package/src/{wrangler-cli.ts → build/wrangler-cli.ts} +44 -6
  60. package/src/cloudflare.ts +153 -223
  61. package/src/index.ts +4 -4
  62. package/src/params.ts +13 -17
  63. package/src/worker/index.ts +276 -0
  64. package/src/worker/tsconfig.json +10 -0
  65. package/template/astrale.config.ts +7 -7
  66. package/dist/assets-pack.d.ts +0 -8
  67. package/dist/assets-pack.d.ts.map +0 -1
  68. package/dist/assets-pack.js +0 -30
  69. package/dist/assets-pack.js.map +0 -1
  70. package/dist/build.d.ts.map +0 -1
  71. package/dist/build.js.map +0 -1
  72. package/dist/client.d.ts +0 -17
  73. package/dist/client.d.ts.map +0 -1
  74. package/dist/client.js +0 -59
  75. package/dist/client.js.map +0 -1
  76. package/dist/codegen/identity.d.ts.map +0 -1
  77. package/dist/codegen/identity.js.map +0 -1
  78. package/dist/codegen/merge.d.ts.map +0 -1
  79. package/dist/codegen/merge.js.map +0 -1
  80. package/dist/codegen/worker.d.ts +0 -51
  81. package/dist/codegen/worker.d.ts.map +0 -1
  82. package/dist/codegen/worker.js +0 -120
  83. package/dist/codegen/worker.js.map +0 -1
  84. package/dist/codegen/wrangler.d.ts.map +0 -1
  85. package/dist/codegen/wrangler.js.map +0 -1
  86. package/dist/parse-output.d.ts.map +0 -1
  87. package/dist/parse-output.js.map +0 -1
  88. package/dist/wrangler-cli.d.ts.map +0 -1
  89. package/dist/wrangler-cli.js.map +0 -1
  90. package/src/assets-pack.ts +0 -32
  91. package/src/client.ts +0 -89
  92. package/src/codegen/worker.ts +0 -136
  93. package/template/.env.example +0 -9
  94. package/template/.gitignore.template +0 -16
  95. package/template/CLAUDE.md +0 -13
  96. package/template/README.md +0 -47
  97. package/template/client/.gitignore.template +0 -4
  98. package/template/client/__tests__/app.test.tsx +0 -15
  99. package/template/client/components.json +0 -25
  100. package/template/client/index.html +0 -12
  101. package/template/client/package.json +0 -35
  102. package/template/client/src/app.tsx +0 -52
  103. package/template/client/src/components/ui/cn.ts +0 -6
  104. package/template/client/src/main.tsx +0 -9
  105. package/template/client/src/styles.css +0 -78
  106. package/template/client/tsconfig.json +0 -26
  107. package/template/client/vite.config.ts +0 -22
  108. package/template/client/vitest.config.ts +0 -13
  109. package/template/core/README.md +0 -7
  110. package/template/deps.ts +0 -9
  111. package/template/domain.ts +0 -11
  112. package/template/env.ts +0 -8
  113. package/template/icons.ts +0 -4
  114. package/template/integrations/README.md +0 -7
  115. package/template/oxlint.config.ts +0 -1
  116. package/template/package.json +0 -58
  117. package/template/pnpm-workspace.yaml +0 -31
  118. package/template/runtime/index.ts +0 -8
  119. package/template/schema/index.ts +0 -10
  120. package/template/simulation/README.md +0 -12
  121. package/template/tsconfig.json +0 -28
  122. package/template/vitest.config.ts +0 -7
  123. /package/dist/{codegen → build/codegen}/identity.d.ts +0 -0
  124. /package/dist/{codegen → build/codegen}/identity.js +0 -0
  125. /package/dist/{codegen → build/codegen}/merge.d.ts +0 -0
  126. /package/dist/{codegen → build/codegen}/merge.js +0 -0
  127. /package/src/{codegen → build/codegen}/identity.ts +0 -0
  128. /package/src/{codegen → build/codegen}/merge.ts +0 -0
@@ -0,0 +1,324 @@
1
+ import type { CompiledFrontendArtifact, DomainDefinition, FrontendFile } from '@astrale-os/sdk'
2
+ import type { ReactFrontendArtifact } from '@astrale-os/sdk/react'
3
+
4
+ import { compileFrontend } from '@astrale-os/sdk'
5
+ import { compileReactEntrypoint } from '@astrale-os/sdk/react'
6
+ import { spawn } from 'node:child_process'
7
+ import { watch } from 'node:fs'
8
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
9
+ import { dirname, join, resolve, sep } from 'node:path'
10
+
11
+ export const FRONTEND_STAGE_DIR = '.astrale/frontend'
12
+ const FRONTEND_REBUILD_DELAY_MS = 75
13
+ let stageGeneration = 0
14
+ const buildQueues = new Map<string, Promise<void>>()
15
+
16
+ /** Materialize one Domain-owned frontend recipe into an immutable adapter artifact. */
17
+ export async function buildFrontend(input: {
18
+ readonly definition: DomainDefinition
19
+ readonly projectDir: string
20
+ readonly onLog?: (line: string) => void
21
+ }): Promise<CompiledFrontendArtifact | undefined> {
22
+ const key = resolve(input.projectDir)
23
+ const previous = buildQueues.get(key) ?? Promise.resolve()
24
+ let release = (): void => {}
25
+ const current = new Promise<void>((resolveBuild) => {
26
+ release = resolveBuild
27
+ })
28
+ const tail = previous.then(() => current)
29
+ buildQueues.set(key, tail)
30
+ await previous
31
+ try {
32
+ return await buildFrontendUnlocked(input)
33
+ } finally {
34
+ release()
35
+ if (buildQueues.get(key) === tail) buildQueues.delete(key)
36
+ }
37
+ }
38
+
39
+ async function buildFrontendUnlocked(input: {
40
+ readonly definition: DomainDefinition
41
+ readonly projectDir: string
42
+ readonly onLog?: (line: string) => void
43
+ }): Promise<CompiledFrontendArtifact | undefined> {
44
+ const artifact = input.definition.frontend
45
+ if (artifact === undefined) return undefined
46
+ if (artifact.source.kind === 'external') {
47
+ return compileFrontend({
48
+ artifact,
49
+ files: [],
50
+ provenance: { adapter: 'cloudflare', source: 'external' },
51
+ })
52
+ }
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
+ }
91
+ const compiled = await compileFrontend({
92
+ artifact,
93
+ baseHref: 'https://frontend.invalid/',
94
+ files,
95
+ provenance: { adapter: 'cloudflare', source: artifact.source.kind },
96
+ })
97
+ await replaceStage(input.projectDir, compiled.files)
98
+ return compiled
99
+ }
100
+
101
+ export interface FrontendWatchHandle {
102
+ stop(): Promise<void>
103
+ }
104
+
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: {
117
+ readonly definition: DomainDefinition
118
+ readonly projectDir: string
119
+ 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 {
125
+ 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)
170
+ }
171
+ }
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)
180
+ }
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
+ )
204
+ }
205
+
206
+ async function readFiles(root: string, current = ''): Promise<readonly FrontendFile[]> {
207
+ const directory = join(root, current)
208
+ const entries = await readdir(directory, { withFileTypes: true })
209
+ const files: FrontendFile[] = []
210
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
211
+ const path = current === '' ? entry.name : `${current}/${entry.name}`
212
+ if (entry.isDirectory()) files.push(...(await readFiles(root, path)))
213
+ else if (entry.isFile())
214
+ files.push({ path, content: new Uint8Array(await readFile(join(root, path))) })
215
+ }
216
+ return files
217
+ }
218
+
219
+ async function replaceStage(projectDir: string, files: readonly FrontendFile[]): Promise<void> {
220
+ const stage = contained(projectDir, FRONTEND_STAGE_DIR, 'frontend stage')
221
+ const generation = `${process.pid}-${++stageGeneration}`
222
+ const next = `${stage}.next-${generation}`
223
+ const previous = `${stage}.previous-${generation}`
224
+ await rm(next, { recursive: true, force: true })
225
+ await mkdir(next, { recursive: true })
226
+ try {
227
+ for (const file of files) {
228
+ const path = contained(next, file.path, 'staged frontend file')
229
+ await mkdir(dirname(path), { recursive: true })
230
+ await writeFile(path, file.content)
231
+ }
232
+ await rm(previous, { recursive: true, force: true })
233
+ let retainedPrevious = false
234
+ try {
235
+ await rename(stage, previous)
236
+ retainedPrevious = true
237
+ } catch (error) {
238
+ if (!isMissing(error)) throw error
239
+ }
240
+ try {
241
+ await rename(next, stage)
242
+ } catch (error) {
243
+ if (retainedPrevious) await rename(previous, stage)
244
+ throw error
245
+ }
246
+ if (retainedPrevious) await rm(previous, { recursive: true, force: true })
247
+ } finally {
248
+ await rm(next, { recursive: true, force: true })
249
+ }
250
+ }
251
+
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
+ function isMissing(error: unknown): boolean {
289
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
290
+ }
291
+
292
+ function contained(root: string, input: string, label: string): string {
293
+ const base = resolve(root)
294
+ const target = resolve(base, input)
295
+ if (target !== base && !target.startsWith(`${base}${sep}`)) {
296
+ throw new TypeError(`${label} must remain within the Domain project root.`)
297
+ }
298
+ return target
299
+ }
300
+
301
+ function runBuild(
302
+ command: string,
303
+ args: readonly string[],
304
+ cwd: string,
305
+ onLog?: (line: string) => void,
306
+ ): Promise<void> {
307
+ return new Promise((resolveBuild, reject) => {
308
+ const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
309
+ let output = ''
310
+ const collect = (buffer: Buffer) => {
311
+ const value = buffer.toString()
312
+ output += value
313
+ for (const line of value.split('\n')) if (line.trim() !== '') onLog?.(line)
314
+ }
315
+ child.stdout?.on('data', collect)
316
+ child.stderr?.on('data', collect)
317
+ child.on('error', reject)
318
+ child.on('exit', (code) =>
319
+ code === 0
320
+ ? resolveBuild()
321
+ : reject(new Error(`frontend build failed (code ${code ?? 1}):\n${output.slice(-1500)}`)),
322
+ )
323
+ })
324
+ }
@@ -10,7 +10,20 @@
10
10
  * build on so the codegen/bundling/dev logic lives in exactly one place.
11
11
  */
12
12
 
13
- export { logTo, prepare } from './cloudflare'
14
- export { buildClient, CLIENT_DIST_DIR, resolveClientDir } from './client'
15
- export type { ClientConfig } from './client'
16
- export { runWranglerBundle, runWranglerDev } from './wrangler-cli'
13
+ export {
14
+ buildFrontend,
15
+ FRONTEND_STAGE_DIR,
16
+ watchFrontend,
17
+ type FrontendWatchHandle,
18
+ type FrontendWatchSubscribe,
19
+ type FrontendWatchSubscription,
20
+ } from './frontend.js'
21
+ export { logTo, prepare } from './prepare.js'
22
+ export { buildCloudflareWorker, type CloudflareWorkerBuild } from './worker.js'
23
+ export {
24
+ bulkPutSecrets,
25
+ runWranglerBundle,
26
+ runWranglerDeploy,
27
+ runWranglerDev,
28
+ runWranglerTypes,
29
+ } from './wrangler-cli.js'
@@ -6,6 +6,7 @@
6
6
 
7
7
  const READY_RE = /(?:Ready on|Listening on|⎔.*?at)\s+(https?:\/\/[^\s]+)/i
8
8
  const WORKERS_DEV_RE = /(https:\/\/[^\s]+\.workers\.dev)/i
9
+ const VERSION_ID_RE = /(?:Current\s+)?Version ID:\s*([a-z0-9_-]+)/i
9
10
 
10
11
  /**
11
12
  * Local dev URL from `wrangler dev` output, normalized to an origin on the given
@@ -41,6 +42,11 @@ export function parseDeployUrl(text: string, route?: string): string | undefined
41
42
  return m ? m[1] : undefined
42
43
  }
43
44
 
45
+ /** Cloudflare version published by the final Wrangler deploy pass. */
46
+ export function parseDeployVersionId(text: string): string | undefined {
47
+ return VERSION_ID_RE.exec(text)?.[1]
48
+ }
49
+
44
50
  /** Normalize a local dev URL to an origin: `0.0.0.0` → `localhost`, fill the port. */
45
51
  export function normalizeLocal(url: string, port: number): string {
46
52
  try {
@@ -0,0 +1,161 @@
1
+ import type {
2
+ BuildCtx,
3
+ CompiledFrontendArtifact,
4
+ DeployCtx,
5
+ FrontendRoute,
6
+ WatchCtx,
7
+ } from '@astrale-os/sdk'
8
+
9
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+
12
+ import type { CloudflareParams } from '../index.js'
13
+
14
+ import { ensureIdentity } from './codegen/identity.js'
15
+ import { generateWorkerEntry } from './codegen/worker.js'
16
+ import { generateWranglerConfig } from './codegen/wrangler.js'
17
+
18
+ const DEFAULT_PORT = 8787
19
+ const DEPLOYMENT_VERSION_BINDING = 'ASTRALE_DEPLOYMENT_VERSION'
20
+ const WORKER_NAME_RE = /^[a-z0-9][a-z0-9-]*$/
21
+
22
+ /** Prepare immutable generated Worker sources and Wrangler configuration. */
23
+ export async function prepare(
24
+ params: CloudflareParams,
25
+ ctx: WatchCtx | DeployCtx | BuildCtx,
26
+ mode: 'build' | 'dev' | 'deploy',
27
+ frontend?: CompiledFrontendArtifact,
28
+ ): Promise<{ configPath: string; fallbackConfigPath?: string; port: number; workerName: string }> {
29
+ if (Object.hasOwn(params.vars ?? {}, DEPLOYMENT_VERSION_BINDING)) {
30
+ throw new Error(
31
+ `cloudflare adapter: \`${DEPLOYMENT_VERSION_BINDING}\` is adapter-managed and cannot be set in \`vars\`.`,
32
+ )
33
+ }
34
+ const astraleDir = join(ctx.projectDir, '.astrale')
35
+ await mkdir(astraleDir, { recursive: true })
36
+ await ensureIdentity(astraleDir, ctx.definition.schema.origin)
37
+
38
+ const name = workerName(params, ctx.definition.schema.origin)
39
+ const router = resolveRouter(params.router)
40
+ const localFrontend =
41
+ ctx.definition.frontend !== undefined && ctx.definition.frontend.source.kind !== 'external'
42
+ if (localFrontend !== (frontend !== undefined && frontend.artifact.source.kind !== 'external')) {
43
+ throw new TypeError('cloudflare adapter: materialized frontend must match definition.frontend.')
44
+ }
45
+
46
+ await writeFileAtomic(
47
+ join(astraleDir, 'worker.gen.ts'),
48
+ generateWorkerEntry({
49
+ origin: ctx.definition.schema.origin,
50
+ ...(router
51
+ ? {
52
+ router: {
53
+ binding: router.binding,
54
+ hostSuffix: router.hostSuffix,
55
+ minLabels: router.minLabels,
56
+ },
57
+ }
58
+ : {}),
59
+ }),
60
+ )
61
+
62
+ const workerUrl =
63
+ mode === 'deploy' && params.route
64
+ ? `https://${params.route}`
65
+ : mode === 'dev' && params.host
66
+ ? params.host
67
+ : undefined
68
+ const vars = {
69
+ ...(workerUrl ? { WORKER_URL: workerUrl } : {}),
70
+ ...(mode === 'dev'
71
+ ? { ...params.vars, ...('secrets' in ctx ? ctx.secrets : {}) }
72
+ : { ...params.vars }),
73
+ }
74
+ const baseConfig = {
75
+ workerName: name,
76
+ ...(mode === 'deploy' && params.route ? { route: params.route } : {}),
77
+ ...(localFrontend
78
+ ? {
79
+ frontend: {
80
+ directory: './frontend',
81
+ routes: localFrontendRoutes(ctx.definition),
82
+ },
83
+ }
84
+ : {}),
85
+ vars,
86
+ ...(router ? { router: { binding: router.binding, service: router.service } } : {}),
87
+ ...(params.wrangler ? { wrangler: params.wrangler } : {}),
88
+ }
89
+
90
+ const configPath = join(astraleDir, 'wrangler.gen.jsonc')
91
+ await writeFileAtomic(configPath, generateWranglerConfig({ ...baseConfig, selfBinding: true }))
92
+
93
+ let fallbackConfigPath: string | undefined
94
+ if (mode === 'deploy') {
95
+ fallbackConfigPath = join(astraleDir, 'wrangler.no-self.gen.jsonc')
96
+ await writeFileAtomic(
97
+ fallbackConfigPath,
98
+ generateWranglerConfig({ ...baseConfig, selfBinding: false }),
99
+ )
100
+ }
101
+
102
+ return {
103
+ configPath,
104
+ ...(fallbackConfigPath ? { fallbackConfigPath } : {}),
105
+ port: params.port ?? DEFAULT_PORT,
106
+ workerName: name,
107
+ }
108
+ }
109
+
110
+ function localFrontendRoutes(
111
+ definition: BuildCtx['definition'] | DeployCtx['definition'] | WatchCtx['definition'],
112
+ ): readonly string[] {
113
+ const frontend = definition.frontend
114
+ 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
+ )
120
+ }
121
+
122
+ async function writeFileAtomic(path: string, content: string): Promise<void> {
123
+ const tmp = `${path}.tmp`
124
+ await writeFile(tmp, content)
125
+ await rename(tmp, path)
126
+ }
127
+
128
+ export function workerName(params: CloudflareParams, origin: string): string {
129
+ if (params.workerName !== undefined) {
130
+ if (!WORKER_NAME_RE.test(params.workerName) || params.workerName.length > 63) {
131
+ throw new Error(
132
+ `cloudflare adapter: invalid \`workerName\` "${params.workerName}". A Cloudflare worker ` +
133
+ 'name must be lowercase, start with a letter or digit, contain only letters, digits and ' +
134
+ 'dashes, and be at most 63 chars.',
135
+ )
136
+ }
137
+ return params.workerName
138
+ }
139
+ return origin
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9]+/g, '-')
142
+ .slice(0, 54)
143
+ .replace(/^-+|-+$/g, '')
144
+ }
145
+
146
+ export function resolveRouter(
147
+ ir: CloudflareParams['router'],
148
+ ): { binding: string; service: string; hostSuffix: string; minLabels: number } | undefined {
149
+ if (ir === false) return undefined
150
+ const cfg = ir ?? {}
151
+ return {
152
+ binding: cfg.binding ?? 'ROUTER',
153
+ service: cfg.service ?? 'admin-router',
154
+ hostSuffix: cfg.hostSuffix ?? '.astrale.ai',
155
+ minLabels: cfg.minLabels ?? 4,
156
+ }
157
+ }
158
+
159
+ export function logTo(): (line: string) => void {
160
+ return (line: string) => process.stderr.write(`\x1b[2m ${line}\x1b[0m\n`)
161
+ }
@@ -0,0 +1,37 @@
1
+ import type { BuildCtx, CompiledFrontendArtifact } from '@astrale-os/sdk'
2
+
3
+ import { join } from 'node:path'
4
+
5
+ import type { CloudflareParams } from '../params.js'
6
+
7
+ import { buildFrontend } from './frontend.js'
8
+ import { prepare } from './prepare.js'
9
+ import { runWranglerBundle } from './wrangler-cli.js'
10
+
11
+ export interface CloudflareWorkerBuild {
12
+ readonly frontend: CompiledFrontendArtifact | undefined
13
+ readonly configPath: string
14
+ readonly bundlePath: string
15
+ }
16
+
17
+ /** One shared frontend/config/Worker qualification lifecycle for Cloudflare-based adapters. */
18
+ export async function buildCloudflareWorker(input: {
19
+ readonly params: CloudflareParams
20
+ readonly context: BuildCtx
21
+ readonly outDir?: string
22
+ readonly onLog?: (line: string) => void
23
+ }): Promise<CloudflareWorkerBuild> {
24
+ const frontend = await buildFrontend({
25
+ definition: input.context.definition,
26
+ projectDir: input.context.projectDir,
27
+ onLog: input.onLog,
28
+ })
29
+ const { configPath } = await prepare(input.params, input.context, 'build', frontend)
30
+ const bundlePath = await runWranglerBundle({
31
+ projectDir: input.context.projectDir,
32
+ configPath,
33
+ outDir: input.outDir ?? join(input.context.projectDir, '.astrale', 'dist-build'),
34
+ onLog: input.onLog,
35
+ })
36
+ return Object.freeze({ frontend, configPath, bundlePath })
37
+ }
@@ -14,9 +14,9 @@ import { existsSync, readdirSync } from 'node:fs'
14
14
  import { mkdtemp, rm, writeFile } from 'node:fs/promises'
15
15
  import { createServer } from 'node:net'
16
16
  import { tmpdir } from 'node:os'
17
- import { join } from 'node:path'
17
+ import { dirname, join } from 'node:path'
18
18
 
19
- import { parseDeployUrl, parseDevReadyUrl } from './parse-output'
19
+ import { parseDeployUrl, parseDeployVersionId, parseDevReadyUrl } from './parse-output.js'
20
20
 
21
21
  function wranglerCommand(projectDir: string): { cmd: string; prefix: string[] } {
22
22
  const local = join(projectDir, 'node_modules', '.bin', 'wrangler')
@@ -69,6 +69,7 @@ export async function runWranglerDev(args: {
69
69
  onReload: () => void
70
70
  onLog?: (line: string) => void
71
71
  }): Promise<DevHandle> {
72
+ await runWranglerTypes(args)
72
73
  const { cmd, prefix } = wranglerCommand(args.projectDir)
73
74
  const bindHost = args.ip ?? '127.0.0.1'
74
75
  if (args.autoPickPort && !(await portFree(args.port, bindHost))) {
@@ -103,10 +104,12 @@ export async function runWranglerDev(args: {
103
104
  // never answers) — the classic silent hang. Track build errors so the
104
105
  // safety net can fail LOUD instead of resolving a dead URL.
105
106
  let buildErrorTail = ''
107
+ let outputTail = ''
106
108
 
107
109
  const url = await new Promise<string>((resolve, reject) => {
108
110
  const onData = (buf: Buffer) => {
109
111
  const text = buf.toString()
112
+ outputTail = (outputTail + text).slice(-3000)
110
113
  for (const line of text.split('\n')) {
111
114
  if (line.trim()) args.onLog?.(line)
112
115
  }
@@ -128,7 +131,11 @@ export async function runWranglerDev(args: {
128
131
  child.on('exit', (code) => {
129
132
  if (!resolved) {
130
133
  resolved = true
131
- reject(new Error(`wrangler dev exited before becoming ready (code ${code ?? 'null'}).`))
134
+ reject(
135
+ new Error(
136
+ `wrangler dev exited before becoming ready (code ${code ?? 'null'}).\n${outputTail}`,
137
+ ),
138
+ )
132
139
  }
133
140
  })
134
141
  child.on('error', (err) => {
@@ -174,7 +181,8 @@ export interface DeployArgs {
174
181
 
175
182
  export async function runWranglerDeploy(
176
183
  args: DeployArgs & { projectDir: string },
177
- ): Promise<{ url: string; raw: string }> {
184
+ ): Promise<{ url: string; raw: string; deploymentVersion?: string }> {
185
+ await runWranglerTypes(args)
178
186
  const { cmd, prefix } = wranglerCommand(args.projectDir)
179
187
  const deploy = (configPath: string) =>
180
188
  runCapture(cmd, [...prefix, 'deploy', '--config', configPath], args.projectDir, args.onLog)
@@ -192,7 +200,7 @@ export async function runWranglerDeploy(
192
200
  export async function deployWithSelfFallback(
193
201
  deploy: (configPath: string) => Promise<{ code: number; out: string }>,
194
202
  args: DeployArgs,
195
- ): Promise<{ url: string; raw: string }> {
203
+ ): Promise<{ url: string; raw: string; deploymentVersion?: string }> {
196
204
  let { code, out } = await deploy(args.configPath)
197
205
 
198
206
  if (code !== 0 && args.fallbackConfigPath && isSelfBindingMissing(out, args.workerName)) {
@@ -215,7 +223,8 @@ export async function deployWithSelfFallback(
215
223
  if (!url) {
216
224
  throw new Error(`could not determine deployed URL from wrangler output:\n${out.slice(-2000)}`)
217
225
  }
218
- return { url, raw: out }
226
+ const deploymentVersion = parseDeployVersionId(out)
227
+ return { url, raw: out, ...(deploymentVersion === undefined ? {} : { deploymentVersion }) }
219
228
  }
220
229
 
221
230
  /**
@@ -309,6 +318,7 @@ export async function runWranglerBundle(args: {
309
318
  outDir: string
310
319
  onLog?: (line: string) => void
311
320
  }): Promise<string> {
321
+ await runWranglerTypes(args)
312
322
  const { cmd, prefix } = wranglerCommand(args.projectDir)
313
323
  const { code, out } = await runCapture(
314
324
  cmd,
@@ -322,3 +332,31 @@ export async function runWranglerBundle(args: {
322
332
  const preferred = js.find((f) => f.startsWith('worker')) ?? js[0]!
323
333
  return join(args.outDir, preferred)
324
334
  }
335
+
336
+ /** Generate the exact binding and runtime types selected by the generated config. */
337
+ export async function runWranglerTypes(args: {
338
+ readonly projectDir: string
339
+ readonly configPath: string
340
+ readonly onLog?: (line: string) => void
341
+ }): Promise<string> {
342
+ const { cmd, prefix } = wranglerCommand(args.projectDir)
343
+ const outputPath = join(dirname(args.configPath), 'worker-configuration.d.ts')
344
+ const { code, out } = await runCapture(
345
+ cmd,
346
+ [
347
+ ...prefix,
348
+ 'types',
349
+ outputPath,
350
+ '--env-interface',
351
+ 'CloudflareEnv',
352
+ '--config',
353
+ args.configPath,
354
+ ],
355
+ args.projectDir,
356
+ args.onLog,
357
+ )
358
+ if (code !== 0) {
359
+ throw new Error(`wrangler types failed (code ${code}):\n${out.slice(-2000)}`)
360
+ }
361
+ return outputPath
362
+ }