@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.
- package/dist/build/codegen/identity.d.ts.map +1 -0
- package/dist/build/codegen/identity.js.map +1 -0
- package/dist/build/codegen/merge.d.ts.map +1 -0
- package/dist/build/codegen/merge.js.map +1 -0
- package/dist/build/codegen/worker.d.ts +12 -0
- package/dist/build/codegen/worker.d.ts.map +1 -0
- package/dist/build/codegen/worker.js +40 -0
- package/dist/build/codegen/worker.js.map +1 -0
- package/dist/{codegen → build/codegen}/wrangler.d.ts +6 -3
- package/dist/build/codegen/wrangler.d.ts.map +1 -0
- package/dist/{codegen → build/codegen}/wrangler.js +29 -10
- package/dist/build/codegen/wrangler.js.map +1 -0
- package/dist/build/frontend.d.ts +26 -0
- package/dist/build/frontend.d.ts.map +1 -0
- package/dist/build/frontend.js +263 -0
- package/dist/build/frontend.js.map +1 -0
- package/dist/{build.js → build/index.d.ts} +5 -4
- package/dist/build/index.d.ts.map +1 -0
- package/dist/{build.d.ts → build/index.js} +5 -5
- package/dist/build/index.js.map +1 -0
- package/dist/{parse-output.d.ts → build/parse-output.d.ts} +2 -0
- package/dist/build/parse-output.d.ts.map +1 -0
- package/dist/{parse-output.js → build/parse-output.js} +5 -0
- package/dist/build/parse-output.js.map +1 -0
- package/dist/build/prepare.d.ts +18 -0
- package/dist/build/prepare.d.ts.map +1 -0
- package/dist/build/prepare.js +117 -0
- package/dist/build/prepare.js.map +1 -0
- package/dist/build/worker.d.ts +15 -0
- package/dist/build/worker.d.ts.map +1 -0
- package/dist/build/worker.js +21 -0
- package/dist/build/worker.js.map +1 -0
- package/dist/{wrangler-cli.d.ts → build/wrangler-cli.d.ts} +8 -0
- package/dist/build/wrangler-cli.d.ts.map +1 -0
- package/dist/{wrangler-cli.js → build/wrangler-cli.js} +28 -4
- package/dist/build/wrangler-cli.js.map +1 -0
- package/dist/cloudflare.d.ts +21 -24
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +125 -191
- package/dist/cloudflare.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/params.d.ts +13 -16
- package/dist/params.d.ts.map +1 -1
- package/dist/worker/index.d.ts +48 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +186 -0
- package/dist/worker/index.js.map +1 -0
- package/package.json +13 -5
- package/src/build/codegen/worker.ts +53 -0
- package/src/{codegen → build/codegen}/wrangler.ts +34 -12
- package/src/build/frontend.ts +324 -0
- package/src/{build.ts → build/index.ts} +17 -4
- package/src/{parse-output.ts → build/parse-output.ts} +6 -0
- package/src/build/prepare.ts +161 -0
- package/src/build/worker.ts +37 -0
- package/src/{wrangler-cli.ts → build/wrangler-cli.ts} +44 -6
- package/src/cloudflare.ts +153 -223
- package/src/index.ts +4 -4
- package/src/params.ts +13 -17
- package/src/worker/index.ts +276 -0
- package/src/worker/tsconfig.json +10 -0
- package/template/astrale.config.ts +7 -7
- package/dist/assets-pack.d.ts +0 -8
- package/dist/assets-pack.d.ts.map +0 -1
- package/dist/assets-pack.js +0 -30
- package/dist/assets-pack.js.map +0 -1
- package/dist/build.d.ts.map +0 -1
- package/dist/build.js.map +0 -1
- package/dist/client.d.ts +0 -17
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -59
- package/dist/client.js.map +0 -1
- package/dist/codegen/identity.d.ts.map +0 -1
- package/dist/codegen/identity.js.map +0 -1
- package/dist/codegen/merge.d.ts.map +0 -1
- package/dist/codegen/merge.js.map +0 -1
- package/dist/codegen/worker.d.ts +0 -51
- package/dist/codegen/worker.d.ts.map +0 -1
- package/dist/codegen/worker.js +0 -120
- package/dist/codegen/worker.js.map +0 -1
- package/dist/codegen/wrangler.d.ts.map +0 -1
- package/dist/codegen/wrangler.js.map +0 -1
- package/dist/parse-output.d.ts.map +0 -1
- package/dist/parse-output.js.map +0 -1
- package/dist/wrangler-cli.d.ts.map +0 -1
- package/dist/wrangler-cli.js.map +0 -1
- package/src/assets-pack.ts +0 -32
- package/src/client.ts +0 -89
- package/src/codegen/worker.ts +0 -136
- package/template/.env.example +0 -9
- package/template/.gitignore.template +0 -16
- package/template/CLAUDE.md +0 -13
- package/template/README.md +0 -47
- package/template/client/.gitignore.template +0 -4
- package/template/client/__tests__/app.test.tsx +0 -15
- package/template/client/components.json +0 -25
- package/template/client/index.html +0 -12
- package/template/client/package.json +0 -35
- package/template/client/src/app.tsx +0 -52
- package/template/client/src/components/ui/cn.ts +0 -6
- package/template/client/src/main.tsx +0 -9
- package/template/client/src/styles.css +0 -78
- package/template/client/tsconfig.json +0 -26
- package/template/client/vite.config.ts +0 -22
- package/template/client/vitest.config.ts +0 -13
- package/template/core/README.md +0 -7
- package/template/deps.ts +0 -9
- package/template/domain.ts +0 -11
- package/template/env.ts +0 -8
- package/template/icons.ts +0 -4
- package/template/integrations/README.md +0 -7
- package/template/oxlint.config.ts +0 -1
- package/template/package.json +0 -58
- package/template/pnpm-workspace.yaml +0 -31
- package/template/runtime/index.ts +0 -8
- package/template/schema/index.ts +0 -10
- package/template/simulation/README.md +0 -12
- package/template/tsconfig.json +0 -28
- package/template/vitest.config.ts +0 -7
- /package/dist/{codegen → build/codegen}/identity.d.ts +0 -0
- /package/dist/{codegen → build/codegen}/identity.js +0 -0
- /package/dist/{codegen → build/codegen}/merge.d.ts +0 -0
- /package/dist/{codegen → build/codegen}/merge.js +0 -0
- /package/src/{codegen → build/codegen}/identity.ts +0 -0
- /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 {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|