1import * as Effect from "effect/Effect";
2import type { MemoOptions } from "../../Command/Memo.ts";
3import type { InputProps } from "../../Input.ts";
4import { effectClass } from "../../Util/effect.ts";
5import { Namespace } from "../KV/Namespace.ts";
6import type { Providers } from "../Providers.ts";
7import type { AssetsConfig } from "../Workers/Assets.ts";
8import {
9 Worker,
10 type NormalizedBindings,
11 type WorkerAssetsConfig,
12 type WorkerBindingProps,
13 type WorkerProps,
14} from "../Workers/Worker.ts";
15
16export interface AstroProps<
17 Bindings extends WorkerBindingProps = {},
18> extends Omit<
19 WorkerProps<Bindings>,
20 "vite" | "main" | "assets" | "source" | "script" | "bundle"
21> {
22 /**
23 * Astro project root directory.
24 * Defaults to the current working directory (`process.cwd()`).
25 */
26 rootDir?: string;
27 /**
28 * Controls which files are hashed to decide whether a rebuild is needed.
29 * By default every non-gitignored file under `rootDir` is hashed, plus the
30 * nearest package-manager lockfile. Provide explicit globs to narrow the
31 * scope.
32 */
33 memo?: MemoOptions & {
34 /**
35 * Additional workspace directories to hash (relative to `rootDir`).
36 * By default (`"auto"`), workspaces are auto-detected from the build's
37 * module graph; an explicit array pins them.
38 * @default "auto"
39 */
40 workspaces?: "auto" | Array<MemoOptions & { cwd: string }>;
41 };
42 /**
43 * The name of the KV binding backing Astro's session API.
44 *
45 * A KV namespace is auto-provisioned and bound under this name on
46 * deploy, so `Astro.session` works with zero configuration. Bind your
47 * own KV namespace under this name in `env` to use it instead of the
48 * auto-provisioned one, or set this to `false` to disable session
49 * provisioning entirely.
50 * @default "SESSION"
51 */
52 sessionKVBindingName?: string | false;
53 /**
54 * Runtime used to prerender static pages. `"workerd"` renders them in the
55 * production Worker runtime; `"node"` uses Astro's stock Node prerenderer.
56 * @default "workerd"
57 */
58 prerenderEnvironment?: "workerd" | "node";
59 /**
60 * Deploy-time Astro config overrides, merged OVER your natively-loaded
61 * `astro.config.*` (values here win). Use it for values that vary per
62 * stage or derive from other resources' Outputs — everything else
63 * belongs in the config file.
64 */
65 astro?: {
66 /** Deployed URL origin (astro's `site`). */
67 site?: string;
68 /** Base path the site is served from (astro's `base`). */
69 base?: string;
70 /**
71 * Astro output target — a deploy-topology decision (whether Worker
72 * code runs at request time). `"server"` renders pages on demand in
73 * the Worker; individual pages opt into prerendering with
74 * `export const prerender = true`. `"static"` prerenders every page
75 * at build time and deploys assets-only. Supersedes a file-level
76 * `output`.
77 * @default "server"
78 */
79 output?: "server" | "static";
80 /** Source directory (astro's `srcDir`). */
81 srcDir?: string;
82 /** Public assets directory (astro's `publicDir`). */
83 publicDir?: string;
84 /** Build output directory (astro's `outDir`). */
85 outDir?: string;
86 /** Trailing-slash handling (astro's `trailingSlash`). */
87 trailingSlash?: "always" | "never" | "ignore";
88 };
89 /**
90 * Path to an alternate astro config file, relative to `rootDir`.
91 * Defaults to astro's own config discovery.
92 */
93 config?: string;
94 /**
95 * Optional configuration for static asset routing behavior.
96 * Supports `runWorkerFirst`, `htmlHandling`, `notFoundHandling`, etc.
97 */
98 assets?: AssetsConfig;
99}
100
101// These options are inspected while constructing the Worker. Resolve them in
102// the outer props Effect; pass-through properties can remain deferred Inputs.
103type AstroInput<Bindings extends WorkerBindingProps> = InputProps<
104 AstroProps<Bindings>,
105 "sessionKVBindingName" | "astro" | "env"
106>;
107
108/**
109 * A Cloudflare Worker deployed from an [Astro](https://astro.build)
110 * project.
111 *
112 * `Astro` runs Astro's programmatic build with a wrangler-free
113 * Cloudflare adapter (`@alchemy.run/frontend-frameworks/astro`): server-rendered pages
114 * execute in the Worker, prerendered pages and client assets deploy as
115 * static assets. Your `astro.config.*` loads natively — no adapter
116 * setup or Wrangler configuration required.
117 *
118 * Input files are content-hashed (respecting `.gitignore` by default)
119 * so unchanged projects skip the build and deploy entirely.
120 *
121 * The `@alchemy.run/frontend-frameworks` package must be installed in your
122 * project; its `/astro` export is loaded dynamically at deploy time:
123 *
124 * ```sh
125 * bun add -d @alchemy.run/frontend-frameworks
126 * ```
127 *
128 *
129 * ### Deploying an Astro Site
130 * A single call builds the project and deploys the server bundle plus
131 * static assets. Pages are server-rendered by default; pages that
132 * `export const prerender = true` are served as static assets. Astro's
133 * server runtime is built against Node APIs, so `nodejs_compat` is
134 * always included in the Worker's compatibility flags.
135 *
136 * **Example:** Astro site
137 * ```typescript
138 * const site = yield* Cloudflare.Website.Astro("Website");
139 * ```
140 *
141 * ### Static Sites
142 * With `astro: { output: "static" }` every page is prerendered at build
143 * time and the deploy is **assets-only**: no server bundle is uploaded —
144 * Cloudflare's asset layer answers every request (serve the built
145 * `404.html` via `assets: { notFoundHandling: "404-page" }`). Session
146 * provisioning is skipped for declared-static sites since no Worker
147 * code runs at request time.
148 *
149 * **Example:** Fully static Astro site
150 * ```typescript
151 * const site = yield* Cloudflare.Website.Astro("Docs", {
152 * astro: { output: "static" },
153 * assets: {
154 * notFoundHandling: "404-page",
155 * },
156 * });
157 * ```
158 *
159 * ### Bindings
160 * Bind resources through `env` like any other Worker. Astro code reads
161 * them via `import { env } from "cloudflare:workers"` (or
162 * `Astro.locals.runtime.env`).
163 *
164 * **Example:** Astro site with a KV namespace and an R2 bucket
165 * ```typescript
166 * const kv = yield* Cloudflare.KV.Namespace("Cache");
167 * const bucket = yield* Cloudflare.R2.Bucket("Uploads");
168 *
169 * const site = yield* Cloudflare.Website.Astro("Website", {
170 * env: {
171 * CACHE: kv,
172 * UPLOADS: bucket,
173 * },
174 * });
175 * ```
176 *
177 * ### Sessions
178 * Astro's session API is backed by a KV namespace. One is provisioned
179 * and bound under the session binding name (`SESSION` by default)
180 * automatically, so `Astro.session` works with zero configuration.
181 * Bind your own namespace under that name to use it instead, or set
182 * `sessionKVBindingName: false` to opt out of session provisioning.
183 *
184 * **Example:** Bringing your own session namespace
185 * ```typescript
186 * const sessions = yield* Cloudflare.KV.Namespace("Sessions");
187 *
188 * const site = yield* Cloudflare.Website.Astro("Website", {
189 * env: {
190 * SESSION: sessions,
191 * },
192 * });
193 * ```
194 *
195 * **Example:** Opting out of session provisioning
196 * ```typescript
197 * const site = yield* Cloudflare.Website.Astro("Website", {
198 * sessionKVBindingName: false,
199 * });
200 * ```
201 *
202 * ### Custom Rebuild Scope
203 * By default, every non-gitignored file is hashed to decide whether a
204 * rebuild is needed. Use `memo` to narrow the scope when your project
205 * has large directories that don't affect the build output.
206 *
207 * **Example:** Narrowing the memo scope
208 * ```typescript
209 * const site = yield* Cloudflare.Website.Astro("Docs", {
210 * memo: {
211 * include: ["src/**", "public/**", "package.json"],
212 * },
213 * });
214 * ```
215 *
216 * ### Astro Configuration
217 * Your `astro.config.*` is the home for Astro configuration
218 * (integrations, Vite plugins, `site`, `base`, ...) and loads natively.
219 * The Cloudflare adapter is injected for you — declaring an `adapter`
220 * in the config file fails the build. The `astro` prop is a
221 * deploy-time override bag merged OVER the file (values here win) for
222 * settings that vary per stage or derive from other resources'
223 * Outputs, which a config file cannot consume. `output` defaults to
224 * `"server"` — astro's zero-config `"static"` default would prerender
225 * every page inside workerd, where the Worker's bindings don't exist.
226 * Use `config` to point at an alternate config file (relative to
227 * `rootDir`).
228 *
229 * **Example:** Per-stage site URL override
230 * ```typescript
231 * const site = yield* Cloudflare.Website.Astro("Blog", {
232 * astro: { site: "https://blog.example.com" },
233 * });
234 * ```
235 *
236 * ### Class Form
237 * Calling `Astro` with no arguments returns a constructor you can
238 * `extend` to declare the Worker as a named class. The class is both an
239 * `Effect` you can `yield*` to deploy and a type you can reference
240 * elsewhere — useful when other resources need to bind to this Worker.
241 *
242 * **Example:** Declaring a Worker class
243 * ```typescript
244 * class Website extends Cloudflare.Website.Astro<Website>()("Website") {}
245 *
246 * const site = yield* Website;
247 * ```
248 *
249 * @resource
250 * @product Website
251 * @category Workers & Compute
252 */
253export const Astro: {
254 <Self>(): {
255 <const Bindings extends WorkerBindingProps = {}, Req = never>(
256 id: string,
257 propsEff?:
258 | AstroInput<Bindings>
259 | Effect.Effect<AstroInput<Bindings>, never, Req>,
260 ): Effect.Effect<Self, never, Req | Providers> & {
261 new (): Worker<{
262 [
263 binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig>
264 ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding];
265 }>;
266 };
267 };
268 <const Bindings extends WorkerBindingProps = {}, Req = never>(
269 id: string,
270 propsEff?:
271 | AstroInput<Bindings>
272 | Effect.Effect<AstroInput<Bindings>, never, Req>,
273 ): Effect.Effect<
274 Worker<{
275 [
276 binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig>
277 ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding];
278 }>,
279 never,
280 Req | Providers
281 >;
282} = (<const Bindings extends WorkerBindingProps = {}, Req = never>(
283 id?: string,
284 propsEff?:
285 | AstroInput<Bindings>
286 | Effect.Effect<AstroInput<Bindings>, never, Req>,
287) =>
288 id === undefined
289 ? <const Bindings extends WorkerBindingProps = {}, Req = never>(
290 id: string,
291 propsEff?:
292 | AstroInput<Bindings>
293 | Effect.Effect<AstroInput<Bindings>, never, Req>,
294 ) => effectClass(Astro(id, propsEff))
295 : Worker(
296 id,
297 Effect.gen(function* () {
298 const props =
299 (Effect.isEffect(propsEff) ? yield* propsEff : propsEff) ?? {};
300 const session = props.sessionKVBindingName;
301 const sessionBindingName =
302 typeof session === "string" ? session : "SESSION";
303 let env: WorkerBindingProps | undefined = props.env;
304 // Auto-provision the KV namespace backing Astro's session API
305 // unless the user opted out (`sessionKVBindingName: false`) or
306 // already bound their own namespace under the session name.
307 // A declared `output: "static"` site is assets-only — no Worker
308 // script runs at request time, so a session namespace could never
309 // be read; skip provisioning it. Resource creation is deduped by
310 // logical id, so re-evaluating this props effect is safe.
311 if (
312 session !== false &&
313 props.astro?.output !== "static" &&
314 env?.[sessionBindingName] === undefined
315 ) {
316 const sessions = yield* Namespace(`${id}Session`);
317 env = { ...env, [sessionBindingName]: sessions };
318 }
319 return {
320 ...props,
321 env,
322 // Astro's vendored server runtime is built against Node APIs
323 // and needs `nodejs_compat`; `getCompatibility` already adds it
324 // to every non-python Worker (honoring an explicit
325 // `no_nodejs_compat` opt-out and the v2-mode date guard).
326 main: undefined!,
327 source: {
328 provider: "@alchemy.run/frontend-frameworks/astro/source",
329 devMode: "server",
330 rootDir: props.rootDir,
331 options: {
332 rootDir: props.rootDir,
333 memo: props.memo,
334 // Passed through verbatim (including `false`) so the
335 // source provider can skip its session-driver wiring on
336 // opt-out.
337 sessionKVBindingName: props.sessionKVBindingName,
338 prerenderEnvironment: props.prerenderEnvironment,
339 // The `astro` bag is the deploy-time overlay merged OVER
340 // the natively-loaded `astro.config.*`. Server output is
341 // the documented default: astro's own zero-config default
342 // is `"static"`, which would prerender every page at
343 // build time inside workerd — where the Worker's bindings
344 // don't exist.
345 astro: {
346 ...props.astro,
347 output: props.astro?.output ?? "server",
348 },
349 config: props.config,
350 },
351 },
352 };
353 }),
354 )) as any;