| 1 | # @alchemy.run/frontend-frameworks/astro |
| 2 | |
| 3 | Programmatic, wrangler-free Astro integration. |
| 4 | |
| 5 | This package implements framework-core's `Framework` service for Astro — |
| 6 | effectful `build`/`dev` over Astro's public programmatic API (`import { build, |
| 7 | dev } from "astro"`) with a fully in-memory `AstroInlineConfig` (no |
| 8 | `astro.config.*`) — and takes the **deploy target as a value**. The Cloudflare |
| 9 | Workers 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 |
| 14 | import * as Astro from "@alchemy.run/frontend-frameworks/astro"; |
| 15 | import cloudflare from "@alchemy.run/frontend-frameworks/astro/cloudflare"; |
| 16 | |
| 17 | const 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 | |
| 37 | The 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 | |
| 46 | A 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 |
| 52 | framework-specific hook: |
| 53 | |
| 54 | ```ts |
| 55 | interface AstroTarget<Config = unknown> extends DeployTarget<Config> { |
| 56 | /** The Astro adapter integration pinned into AstroInlineConfig.adapter. */ |
| 57 | readonly integration: () => AstroIntegration; |
| 58 | } |
| 59 | ``` |
| 60 | |
| 61 | Everything platform-specific rides inside that integration (Astro's adapter |
| 62 | API is already the right seam: it injects vite plugins, selects the server |
| 63 | entrypoint, and configures the build). The generic `DeployTarget` seams are |
| 64 | honored 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 | |
| 89 | Omitting `target` defaults to `"@alchemy.run/frontend-frameworks/astro/cloudflare"`, so |
| 90 | existing Cloudflare users need no change. The target is resolved once per |
| 91 | `build`/`dev` operation; a resolved target missing the `integration` hook |
| 92 | fails 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 | |
| 114 | Upstream `@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 |
| 118 | of being silently ignored — each error names the reason and the migration. |
| 119 | |
| 120 | ### E2e-harness usage |
| 121 | |
| 122 | The default export is the harness framework factory. It reads the harness's |
| 123 | cloudflare worker options structurally |
| 124 | (`options.target?.cloudflare?.worker ?? options.vite`) and forwards them as |
| 125 | the default target's config: |
| 126 | |
| 127 | ```ts |
| 128 | // e2e.config.ts — untyped carriage |
| 129 | export 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) |
| 135 | framework: (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 | |
| 144 | The integration is a fork of `@astrojs/cloudflare` v14.1.3 (which upstream |
| 145 | builds on `@cloudflare/vite-plugin` + a wrangler peer dependency), reworked to |
| 146 | be 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`, |
| 216 | re-read from disk because Astro injects the serialized SSR manifest after the |
| 217 | bundler finishes) and `clientDirectory` (`dist/client`, captured as a path so |
| 218 | prerendered HTML written after the vite build rides along — including the |
| 219 | generated `_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) |