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

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