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

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 (134) hide show
  1. package/README.md +11 -0
  2. package/dist/build/codegen/identity.d.ts.map +1 -0
  3. package/dist/build/codegen/identity.js.map +1 -0
  4. package/dist/build/codegen/merge.d.ts.map +1 -0
  5. package/dist/build/codegen/merge.js.map +1 -0
  6. package/dist/build/codegen/worker.d.ts +12 -0
  7. package/dist/build/codegen/worker.d.ts.map +1 -0
  8. package/dist/build/codegen/worker.js +40 -0
  9. package/dist/build/codegen/worker.js.map +1 -0
  10. package/dist/{codegen → build/codegen}/wrangler.d.ts +6 -3
  11. package/dist/build/codegen/wrangler.d.ts.map +1 -0
  12. package/dist/{codegen → build/codegen}/wrangler.js +29 -10
  13. package/dist/build/codegen/wrangler.js.map +1 -0
  14. package/dist/build/frontend.d.ts +26 -0
  15. package/dist/build/frontend.d.ts.map +1 -0
  16. package/dist/build/frontend.js +263 -0
  17. package/dist/build/frontend.js.map +1 -0
  18. package/dist/{build.js → build/index.d.ts} +5 -4
  19. package/dist/build/index.d.ts.map +1 -0
  20. package/dist/{build.d.ts → build/index.js} +5 -5
  21. package/dist/build/index.js.map +1 -0
  22. package/dist/{parse-output.d.ts → build/parse-output.d.ts} +2 -0
  23. package/dist/build/parse-output.d.ts.map +1 -0
  24. package/dist/{parse-output.js → build/parse-output.js} +5 -0
  25. package/dist/build/parse-output.js.map +1 -0
  26. package/dist/build/prepare.d.ts +18 -0
  27. package/dist/build/prepare.d.ts.map +1 -0
  28. package/dist/build/prepare.js +117 -0
  29. package/dist/build/prepare.js.map +1 -0
  30. package/dist/build/worker.d.ts +15 -0
  31. package/dist/build/worker.d.ts.map +1 -0
  32. package/dist/build/worker.js +21 -0
  33. package/dist/build/worker.js.map +1 -0
  34. package/dist/{wrangler-cli.d.ts → build/wrangler-cli.d.ts} +10 -0
  35. package/dist/build/wrangler-cli.d.ts.map +1 -0
  36. package/dist/{wrangler-cli.js → build/wrangler-cli.js} +38 -6
  37. package/dist/build/wrangler-cli.js.map +1 -0
  38. package/dist/cloudflare.d.ts +21 -24
  39. package/dist/cloudflare.d.ts.map +1 -1
  40. package/dist/cloudflare.js +125 -191
  41. package/dist/cloudflare.js.map +1 -1
  42. package/dist/index.d.ts +4 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -3
  45. package/dist/index.js.map +1 -1
  46. package/dist/params.d.ts +13 -16
  47. package/dist/params.d.ts.map +1 -1
  48. package/dist/worker/fetch.d.ts +12 -0
  49. package/dist/worker/fetch.d.ts.map +1 -0
  50. package/dist/worker/fetch.js +32 -0
  51. package/dist/worker/fetch.js.map +1 -0
  52. package/dist/worker/index.d.ts +61 -0
  53. package/dist/worker/index.d.ts.map +1 -0
  54. package/dist/worker/index.js +212 -0
  55. package/dist/worker/index.js.map +1 -0
  56. package/package.json +13 -5
  57. package/src/build/codegen/worker.ts +53 -0
  58. package/src/{codegen → build/codegen}/wrangler.ts +34 -12
  59. package/src/build/frontend.ts +324 -0
  60. package/src/{build.ts → build/index.ts} +17 -4
  61. package/src/{parse-output.ts → build/parse-output.ts} +6 -0
  62. package/src/build/prepare.ts +161 -0
  63. package/src/build/worker.ts +37 -0
  64. package/src/{wrangler-cli.ts → build/wrangler-cli.ts} +56 -8
  65. package/src/cloudflare.ts +153 -223
  66. package/src/index.ts +4 -4
  67. package/src/params.ts +13 -17
  68. package/src/worker/fetch.ts +46 -0
  69. package/src/worker/index.ts +325 -0
  70. package/src/worker/tsconfig.json +10 -0
  71. package/template/astrale.config.ts +7 -7
  72. package/dist/assets-pack.d.ts +0 -8
  73. package/dist/assets-pack.d.ts.map +0 -1
  74. package/dist/assets-pack.js +0 -30
  75. package/dist/assets-pack.js.map +0 -1
  76. package/dist/build.d.ts.map +0 -1
  77. package/dist/build.js.map +0 -1
  78. package/dist/client.d.ts +0 -17
  79. package/dist/client.d.ts.map +0 -1
  80. package/dist/client.js +0 -59
  81. package/dist/client.js.map +0 -1
  82. package/dist/codegen/identity.d.ts.map +0 -1
  83. package/dist/codegen/identity.js.map +0 -1
  84. package/dist/codegen/merge.d.ts.map +0 -1
  85. package/dist/codegen/merge.js.map +0 -1
  86. package/dist/codegen/worker.d.ts +0 -51
  87. package/dist/codegen/worker.d.ts.map +0 -1
  88. package/dist/codegen/worker.js +0 -120
  89. package/dist/codegen/worker.js.map +0 -1
  90. package/dist/codegen/wrangler.d.ts.map +0 -1
  91. package/dist/codegen/wrangler.js.map +0 -1
  92. package/dist/parse-output.d.ts.map +0 -1
  93. package/dist/parse-output.js.map +0 -1
  94. package/dist/wrangler-cli.d.ts.map +0 -1
  95. package/dist/wrangler-cli.js.map +0 -1
  96. package/src/assets-pack.ts +0 -32
  97. package/src/client.ts +0 -89
  98. package/src/codegen/worker.ts +0 -136
  99. package/template/.env.example +0 -9
  100. package/template/.gitignore.template +0 -16
  101. package/template/CLAUDE.md +0 -13
  102. package/template/README.md +0 -47
  103. package/template/client/.gitignore.template +0 -4
  104. package/template/client/__tests__/app.test.tsx +0 -15
  105. package/template/client/components.json +0 -25
  106. package/template/client/index.html +0 -12
  107. package/template/client/package.json +0 -35
  108. package/template/client/src/app.tsx +0 -52
  109. package/template/client/src/components/ui/cn.ts +0 -6
  110. package/template/client/src/main.tsx +0 -9
  111. package/template/client/src/styles.css +0 -78
  112. package/template/client/tsconfig.json +0 -26
  113. package/template/client/vite.config.ts +0 -22
  114. package/template/client/vitest.config.ts +0 -13
  115. package/template/core/README.md +0 -7
  116. package/template/deps.ts +0 -9
  117. package/template/domain.ts +0 -11
  118. package/template/env.ts +0 -8
  119. package/template/icons.ts +0 -4
  120. package/template/integrations/README.md +0 -7
  121. package/template/oxlint.config.ts +0 -1
  122. package/template/package.json +0 -58
  123. package/template/pnpm-workspace.yaml +0 -31
  124. package/template/runtime/index.ts +0 -8
  125. package/template/schema/index.ts +0 -10
  126. package/template/simulation/README.md +0 -12
  127. package/template/tsconfig.json +0 -28
  128. package/template/vitest.config.ts +0 -7
  129. /package/dist/{codegen → build/codegen}/identity.d.ts +0 -0
  130. /package/dist/{codegen → build/codegen}/identity.js +0 -0
  131. /package/dist/{codegen → build/codegen}/merge.d.ts +0 -0
  132. /package/dist/{codegen → build/codegen}/merge.js +0 -0
  133. /package/src/{codegen → build/codegen}/identity.ts +0 -0
  134. /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
+ }