1# @alchemy.run/frontend-frameworks/astro
2
3Programmatic, wrangler-free Astro integration.
4
5This package implements framework-core's `Framework` service for Astro —
6effectful `build`/`dev` over Astro's public programmatic API (`import { build,
7dev } from "astro"`) with a fully in-memory `AstroInlineConfig` (no
8`astro.config.*`) — and takes the **deploy target as a value**. The Cloudflare
9Workers target (a fork of `@astrojs/cloudflare` over
10`@alchemy.run/cloudflare-runtime/vite`, with no wrangler dependency and no
11`wrangler.json` anywhere) ships at the `./cloudflare` subpath.
12
13```ts
14import * as Astro from "@alchemy.run/frontend-frameworks/astro";
15import cloudflare from "@alchemy.run/frontend-frameworks/astro/cloudflare";
16
17const layer = Astro.make({
18 target: cloudflare({
19 worker: {
20 compatibilityDate: "2026-03-10",
21 compatibilityFlags: ["nodejs_compat"],
22 worker: {
23 name: "my-app",
24 bindings: [
25 /* in-memory bindings */
26 ],
27 },
28 },
29 }),
30 astro: { site: "https://example.com" },
31});
32// layer: Layer<Framework> — Framework.build → BuildOutput, Framework.dev → { url }
33```
34
35## Architecture: framework half × deploy-target half
36
37The package separates two concerns (see `packages/frontend-frameworks/src/core/README.md`,
38"Architecture: frameworks × deploy targets", for the full doctrine):
39
40| Half | Modules | Contents |
41| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42| **Framework (platform-neutral)** | `src/astro/index.ts`, `src/astro/Astro.ts`, `src/astro/Target.ts`, `src/astro/environments.ts` | The `Framework` service implementation: inline-config synthesis (`makeAstroInlineConfig`), programmatic `build()`/`dev()` driving, the shared build-output collector (`entryEnvironment: "ssr"`, `skipEnvironments: ["astro", "prerender"]`), deploy-target resolution. **Zero Cloudflare imports** — enforced by `src/astro/test/decoupling.test.ts`. |
43| **Cloudflare target** | `src/astro/cloudflare.ts` (subpath `@alchemy.run/frontend-frameworks/astro/cloudflare`), `src/astro/integration.ts`, `src/astro/config-plugin.ts`, `src/astro/prerenderer.ts`, `src/astro/prerender-environment.ts`, `src/astro/prerender-middleware.ts`, `src/astro/runtime/**` | The `AstroTarget` factory plus everything Cloudflare: the forked `@astrojs/cloudflare` integration over our vite plugin, the vendored runtime entrypoints, the `virtual:astro-cloudflare:config` plugin, the workerd prerenderer (build-side driver of the `__astro_*` protocol over cloudflare-runtime), the dev node-prerender middleware plugin. |
44| **alchemy source provider** | `src/astro/source.ts` (subpath `@alchemy.run/frontend-frameworks/astro/source`) | The alchemy `Cloudflare.Workers` source module (structural `SourceProvider` mirror): `build`/`hash`/`dev` for the alchemy Worker resource. Cloudflare-specific by definition; constructs the Cloudflare target directly. |
45
46A future platform (e.g. AWS) is a new subpath implementing the same
47`AstroTarget` seams — no change to the framework half or to framework-core.
48
49### The `AstroTarget` contract
50
51`AstroTarget` extends framework-core's generic `DeployTarget` with one
52framework-specific hook:
53
54```ts
55interface AstroTarget<Config = unknown> extends DeployTarget<Config> {
56 /** The Astro adapter integration pinned into AstroInlineConfig.adapter. */
57 readonly integration: () => AstroIntegration;
58}
59```
60
61Everything platform-specific rides inside that integration (Astro's adapter
62API is already the right seam: it injects vite plugins, selects the server
63entrypoint, and configures the build). The generic `DeployTarget` seams are
64honored by the framework half:
65
66- `target.build` — **wholesale build takeover**: when defined, `Framework.build`
67 delegates the entire production build to the target. (The Cloudflare Astro
68 target does not define it — Astro drives its own build.)
69- `finish` — a post-build finishing pass, applied via
70 `applyDeployTargetFinish` after the collector produces the `BuildOutput`
71 (the Astro build is delivered in-memory, so the finish context carries no
72 on-disk `entry`).
73- `bundle` — resolve/bundle metadata (`conditions`, `external`); informational
74 for Astro since the integration configures the bundler itself.
75- `serve` — local serving of _built_ output; the e2e harness's Cloudflare
76 target provides this (miniflare). Not the HMR dev path.
77
78### How the target is passed
79
80`Astro.make({ target, targetConfig })` accepts a `DeployTargetInput`:
81
82- an `AstroTarget` **value** — used as-is (full type safety; build it yourself
83 by importing the target module),
84- a **factory** `(config) => AstroTarget` — applied to `targetConfig`,
85- a **module specifier** string — loaded from the _project's_ `node_modules`
86 (its `default` — or named `target` — export is the value or factory),
87 applied to `targetConfig`.
88
89Omitting `target` defaults to `"@alchemy.run/frontend-frameworks/astro/cloudflare"`, so
90existing Cloudflare users need no change. The target is resolved once per
91`build`/`dev` operation; a resolved target missing the `integration` hook
92fails with a typed `FrameworkError`.
93
94## Options
95
96### `Astro.make(options?: AstroFrameworkOptions)`
97
98| Option | Type | Description |
99| -------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100| `target` | `AstroTargetInput` | Deploy target (value / factory / specifier). Default: `"@alchemy.run/frontend-frameworks/astro/cloudflare"`. |
101| `targetConfig` | `unknown` | Config handed to a target factory / specifier-loaded module. Opaque to the framework half. Unused when `target` is a value. |
102| `astro` | `AstroInlineConfig` | Extra Astro config merged into the in-memory inline config (`site`, `base`, `redirects`, `integrations`, `devToolbar`, `vite`, ...). `root`, `configFile: false`, and `adapter` are pinned; `output` defaults to `"server"`. |
103| `root` | `string` | Project root. Defaults to `process.cwd()`. |
104
105### Cloudflare target config (`cloudflare(config)`)
106
107| Option | Type | Description |
108| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
109| `worker` | `CloudflareVitePluginOptions` | Compatibility date/flags, worker name/bindings/assets behavior, runtime context — everything a `wrangler.json` would have carried, in-memory. `main`, `viteEnvironments`, and Astro's node environments in `skipEnvironments` are managed by the integration. |
110| `sessionKVBindingName` | `string` | KV binding name used for zero-config sessions and injected into Astro's session config when present on the Worker env. Default `"SESSION"`. |
111| `sessions` | `boolean` | Zero-config sessions (default `true`): when no `session.driver` is configured, Astro's `cloudflareKVBinding` driver is configured against `sessionKVBindingName`. `false` leaves the session config untouched. |
112| `sessionDevKV` | `boolean` | In dev, auto-inject an in-memory local KV namespace (`KvNamespace.local`) for the session binding (default `true`). Disable when the dev worker bindings already deliver a KV binding with that name — binding names must be unique. |
113
114Upstream `@astrojs/cloudflare` adapter options this fork does **not** support
115(`imageService`, `imagesBindingName`, `platformProxy`, `configPath`,
116`persistState`, `auxiliaryWorkers`, `inspectorPort`, `viteEnvironment`,
117`cloudflareModules`, `experimental`) are rejected with a clear error instead
118of being silently ignored — each error names the reason and the migration.
119
120### E2e-harness usage
121
122The default export is the harness framework factory. It reads the harness's
123cloudflare worker options structurally
124(`options.target?.cloudflare?.worker ?? options.vite`) and forwards them as
125the default target's config:
126
127```ts
128// e2e.config.ts — untyped carriage
129export default Options.make({
130 target: { cloudflare: { worker: { /* ... */ }, preview: { /* miniflare */ } } },
131 framework: "@alchemy.run/frontend-frameworks/astro",
132});
133
134// e2e.config.ts — typed target value (recommended; see packages/frontend-frameworks/fixtures/astro)
135framework: (options) =>
136 Astro.make({
137 target: cloudflare({ worker: Options.resolveCloudflareOptions(options).worker }),
138 astro: { devToolbar: { enabled: false } },
139 }),
140```
141
142## What the Cloudflare target does
143
144The integration is a fork of `@astrojs/cloudflare` v14.1.3 (which upstream
145builds on `@cloudflare/vite-plugin` + a wrangler peer dependency), reworked to
146be wrangler-free over `@alchemy.run/cloudflare-runtime/vite`:
147
148- **Swapped plugin.** `@cloudflare/vite-plugin` → our plugin, `main` pinned to
149 the vendored server entrypoint (`@alchemy.run/frontend-frameworks/astro/entrypoints/server`),
150 worker environment `ssr`, Astro's node-side `astro`/`prerender` environments
151 in `skipEnvironments`. Dev SSR executes inside workerd via the vite module
152 runner with in-memory bindings; the dev `env.ASSETS` 404/asset fallback is
153 satisfied by the runtime's vite-aware assets loopback (`Assets.local`).
154- **Dropped.** `loadWranglerEnv` + `.dev.vars`, wrangler-config file watchers,
155 the `previewEntrypoint` (our runtime serves the build output), and the
156 output-`wrangler.json` patch.
157- **Workerd prerendering (default, like upstream).**
158 `prerenderEnvironment: "workerd"` builds the `prerender` environment as a
159 Worker (same treatment as the `ssr` environment) and serves it from workerd
160 via `cloudflare-runtime` — no Vite preview server, no wrangler — while the
161 build drives upstream's `__astro_*` prerender HTTP protocol against it
162 (`src/astro/prerenderer.ts`). Prerendered pages run in the production runtime:
163 top-level `cloudflare:workers` imports and the configured worker bindings
164 work at prerender time. `prerenderEnvironment: "node"` falls back to
165 Astro's stock node prerenderer (+ the dev node-prerender middleware
166 plugin) for pages that need Node-only APIs at build time.
167- **Hardwired.** The `passthrough` image service (see limitations).
168- **Kept (vendored).** The runtime entrypoints/handler/helpers (verified free
169 of wrangler/`@cloudflare/vite-plugin` imports — enforced by the "vendored
170 runtime purity" test), the `virtual:astro-cloudflare:config` plugin (not
171 exported upstream), the `optimizeDeps.include` environment plugin, the
172 `cf-imports`/`cf-externals` plugins, and the `astro:build:setup` server
173 tweaks (`ssr.noExternal`, `sharp` external, process-env banner).
174- **Kept (build-output parity).** The `astro:build:done` pass mirrors
175 upstream: `_redirects` is generated from Astro's redirect routes (config
176 `redirects` + redirect route files, via `@astrojs/underscore-redirects`) so
177 static/prerendered redirects are served by the asset layer ahead of the
178 Worker; `_headers` gets an immutable
179 `Cache-Control: public, max-age=31536000, immutable` rule for the hashed
180 `_astro/` assets directory (skipped when the user's `_headers` already sets
181 Cache-Control on a matching rule, or when `build.assetsPrefix` points assets
182 at another origin); `injectTypes` installs the `App.Locals` runtime types
183 (`@alchemy.run/frontend-frameworks/astro/types.d.ts`).
184- **Kept (`base !== "/"`).** The client build is nested under the base so the
185 assets directory serves at the URL root, the special files
186 (`.assetsignore`/`_headers`/`_redirects`) are moved back up, and the
187 target's `finish` pass points `BuildOutput.clientDirectory` back at the
188 original directory (the in-memory equivalent of upstream's emitted
189 wrangler.json `assets.directory` patch).
190- **Sessions (zero-config).** Mirrors upstream: with no configured
191 `session.driver`, Astro's `cloudflareKVBinding` driver is configured
192 against the `sessionKVBindingName` binding (`SESSION`); the vendored
193 runtime handler injects the live KV binding from the Worker env on each
194 request. In dev, an in-memory `KvNamespace.local` namespace is auto-added
195 to the worker bindings so `Astro.session` works with zero setup (disable
196 via `sessionDevKV: false` when your dev bindings already carry that
197 binding). In production the Worker must be deployed with a KV binding named
198 `sessionKVBindingName` (with alchemy: give the Worker a KV namespace
199 binding named `SESSION`).
200- **`astro:env` at build time.** The alchemy source provider
201 (`@alchemy.run/frontend-frameworks/astro/source`) feeds the Worker's literal env values
202 (strings, `Redacted` strings, numbers/booleans) into `process.env` before
203 `build`/`dev`, so `astro:env` server secrets resolve during config load and
204 node prerendering — the role upstream's `loadWranglerEnv` (wrangler `vars`
205 - `.dev.vars`) plays.
206- **Typegen guard.** `build`/`sync` run Astro's type generation, which boots a
207 temporary vite server; the integration strips `configureServer` from the
208 cloudflare plugins in those phases (mirroring upstream) so workerd never
209 boots mid-build — our dev environments degrade to runnable stubs, a
210 supported contract of the dev plugin.
211
212### Build output
213
214`Framework.build` returns the `BuildOutput` contract in-memory:
215`serverModules` (entry first — the `ssr` entry chunk `server/entry.mjs`,
216re-read from disk because Astro injects the serialized SSR manifest after the
217bundler finishes) and `clientDirectory` (`dist/client`, captured as a path so
218prerendered HTML written after the vite build rides along — including the
219generated `_redirects` and `_headers` files). No `wrangler.json`, no
220`.wrangler/` directory, anywhere.
221
222## Limitations
223
224- **Node prerender fallback loses worker APIs.** With
225 `prerenderEnvironment: "node"`, prerendered routes
226 (`export const prerender = true`) execute in Astro's stock **node**
227 prerender environment: pages that import `cloudflare:workers` (or otherwise
228 rely on worker-only APIs) at prerender time will fail to prerender there.
229 The default `"workerd"` mode does not have this gap — prerendering runs in
230 workerd with the worker's bindings attached.
231- **Image service is passthrough.** workerd cannot run sharp, and the `IMAGES`
232 binding is remote-only in our runtime, so the integration hardwires Astro's
233 `passthrough` image service: images are served as-is (no resizing /
234 format negotiation). The production endpoint is the vendored
235 `image-passthrough-endpoint`; dev uses Astro's generic node endpoint.
236- **Sessions need a deployed KV binding in production.** Zero-config sessions
237 work out of the box in dev (in-memory local KV) and configure the built app
238 to read the `sessionKVBindingName` KV binding from the Worker env — but
239 provisioning that KV namespace on the deployed Worker is the deployment
240 tool's job. Without it, session operations fail at runtime.
241- **`astro preview` is not used.** The upstream preview entrypoint hard-depends
242 on a wrangler deploy config; serving built output is the deploy target's
243 `serve` concern (miniflare in the e2e harness; the alchemy dev loop uses
244 cloudflare-runtime).
245- **Version pinning.** Astro's JS API is `@experimental`, and the integration
246 internals (virtual module names, adapter features) are versioned with Astro
247 majors. The fork tracks `astro` 7.x / adapter v14.1.3; treat version bumps
248 as deliberate migrations backed by the fixture e2e suite.
249
250## Testing
251
252- `bun run test` — unit tests (`test/Astro.test.ts`: target module, config
253 synthesis, integration hooks, wholesale-build delegation;
254 `test/parity.test.ts`: sessions, `_redirects`/`_headers` generation,
255 `base !== "/"` handling, `injectTypes`, the unsupported-option guard, env
256 feeding; `test/decoupling.test.ts`: the framework half's cloudflare-free
257 guarantee and runtime purity).
258- `packages/frontend-frameworks/fixtures/astro` — the playwright e2e suite driving a real app through the
259 harness in both `dev` (workerd module runner) and `live` (miniflare over the
260 built output) modes: SSR + bindings, middleware locals/headers, config
261 redirects (served from the generated `_redirects` in live mode),
262 `Astro.session` round-trips, immutable asset `Cache-Control`, JSON/binary
263 endpoints, content collections, prerendered pages, public assets, ASSETS
264 404 fallback. (A `base !== "/"` fixture app is not yet part of the suite —
265 the behavior is unit-tested in `test/parity.test.ts`.)
266
267## Upstream references
268
269- Astro: [`a2240c43830e29c1d85667f4c610655d6b10253f`](https://github.com/withastro/astro/tree/a2240c43830e29c1d85667f4c610655d6b10253f)