@distilled.cloud/astro
v0.17.1
Published
Programmatic Astro integration implementing the framework-core Framework service (build/dev), with the deploy target passed as a value; ships the wrangler-free Cloudflare Workers target (a fork of @astrojs/cloudflare over @distilled.cloud/cloudflare-vite-
Readme
@distilled.cloud/astro
Programmatic, wrangler-free Astro integration.
This package implements framework-core's Framework service for Astro —
effectful build/dev over Astro's public programmatic API (import { build,
dev } from "astro") with a fully in-memory AstroInlineConfig (no
astro.config.*) — and takes the deploy target as a value. The Cloudflare
Workers target (a fork of @astrojs/cloudflare over
@distilled.cloud/cloudflare-vite-plugin, with no wrangler dependency and no
wrangler.json anywhere) ships at the ./cloudflare subpath.
import * as Astro from "@distilled.cloud/astro";
import cloudflare from "@distilled.cloud/astro/cloudflare";
const layer = Astro.make({
target: cloudflare({
worker: {
compatibilityDate: "2026-03-10",
compatibilityFlags: ["nodejs_compat"],
worker: {
name: "my-app",
bindings: [
/* in-memory bindings */
],
},
},
}),
astro: { site: "https://example.com" },
});
// layer: Layer<Framework> — Framework.build → BuildOutput, Framework.dev → { url }Architecture: framework half × deploy-target half
The package separates two concerns (see packages/framework-core/README.md,
"Architecture: frameworks × deploy targets", for the full doctrine):
| Half | Modules | Contents |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Framework (platform-neutral) | src/index.ts, src/Astro.ts, src/Target.ts, src/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 test/decoupling.test.ts. |
| Cloudflare target | src/cloudflare.ts (subpath @distilled.cloud/astro/cloudflare), src/integration.ts, src/config-plugin.ts, src/prerenderer.ts, src/prerender-environment.ts, src/prerender-middleware.ts, src/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. |
| alchemy source provider | src/source.ts (subpath @distilled.cloud/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. |
A future platform (e.g. AWS) is a new subpath implementing the same
AstroTarget seams — no change to the framework half or to framework-core.
The AstroTarget contract
AstroTarget extends framework-core's generic DeployTarget with one
framework-specific hook:
interface AstroTarget<Config = unknown> extends DeployTarget<Config> {
/** The Astro adapter integration pinned into AstroInlineConfig.adapter. */
readonly integration: () => AstroIntegration;
}Everything platform-specific rides inside that integration (Astro's adapter
API is already the right seam: it injects vite plugins, selects the server
entrypoint, and configures the build). The generic DeployTarget seams are
honored by the framework half:
target.build— wholesale build takeover: when defined,Framework.builddelegates the entire production build to the target. (The Cloudflare Astro target does not define it — Astro drives its own build.)finish— a post-build finishing pass, applied viaapplyDeployTargetFinishafter the collector produces theBuildOutput(the Astro build is delivered in-memory, so the finish context carries no on-diskentry).bundle— resolve/bundle metadata (conditions,external); informational for Astro since the integration configures the bundler itself.serve— local serving of built output; the e2e harness's Cloudflare target provides this (miniflare). Not the HMR dev path.
How the target is passed
Astro.make({ target, targetConfig }) accepts a DeployTargetInput:
- an
AstroTargetvalue — used as-is (full type safety; build it yourself by importing the target module), - a factory
(config) => AstroTarget— applied totargetConfig, - a module specifier string — loaded from the project's
node_modules(itsdefault— or namedtarget— export is the value or factory), applied totargetConfig.
Omitting target defaults to "@distilled.cloud/astro/cloudflare", so
existing Cloudflare users need no change. The target is resolved once per
build/dev operation; a resolved target missing the integration hook
fails with a typed FrameworkError.
Options
Astro.make(options?: AstroFrameworkOptions)
| Option | Type | Description |
| -------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| target | AstroTargetInput | Deploy target (value / factory / specifier). Default: "@distilled.cloud/astro/cloudflare". |
| targetConfig | unknown | Config handed to a target factory / specifier-loaded module. Opaque to the framework half. Unused when target is a value. |
| 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". |
| root | string | Project root. Defaults to process.cwd(). |
Cloudflare target config (cloudflare(config))
| Option | Type | Description |
| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |
| 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". |
| 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. |
| 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. |
Upstream @astrojs/cloudflare adapter options this fork does not support
(imageService, imagesBindingName, platformProxy, configPath,
persistState, auxiliaryWorkers, inspectorPort, viteEnvironment,
cloudflareModules, experimental) are rejected with a clear error instead
of being silently ignored — each error names the reason and the migration.
E2e-harness usage
The default export is the harness framework factory. It reads the harness's
cloudflare worker options structurally
(options.target?.cloudflare?.worker ?? options.vite) and forwards them as
the default target's config:
// e2e.config.ts — untyped carriage
export default Options.make({
target: { cloudflare: { worker: { /* ... */ }, preview: { /* miniflare */ } } },
framework: "@distilled.cloud/astro",
});
// e2e.config.ts — typed target value (recommended; see fixtures/astro)
framework: (options) =>
Astro.make({
target: cloudflare({ worker: Options.resolveCloudflareOptions(options).worker }),
astro: { devToolbar: { enabled: false } },
}),What the Cloudflare target does
The integration is a fork of @astrojs/cloudflare v14.1.3 (which upstream
builds on @cloudflare/vite-plugin + a wrangler peer dependency), reworked to
be wrangler-free over @distilled.cloud/cloudflare-vite-plugin:
- Swapped plugin.
@cloudflare/vite-plugin→ our plugin,mainpinned to the vendored server entrypoint (@distilled.cloud/astro/entrypoints/server), worker environmentssr, Astro's node-sideastro/prerenderenvironments inskipEnvironments. Dev SSR executes inside workerd via the vite module runner with in-memory bindings; the devenv.ASSETS404/asset fallback is satisfied by the runtime's vite-aware assets loopback (Assets.local). - Dropped.
loadWranglerEnv+.dev.vars, wrangler-config file watchers, thepreviewEntrypoint(our runtime serves the build output), and the output-wrangler.jsonpatch. - Workerd prerendering (default, like upstream).
prerenderEnvironment: "workerd"builds theprerenderenvironment as a Worker (same treatment as thessrenvironment) and serves it from workerd viacloudflare-runtime— no Vite preview server, no wrangler — while the build drives upstream's__astro_*prerender HTTP protocol against it (src/prerenderer.ts). Prerendered pages run in the production runtime: top-levelcloudflare:workersimports and the configured worker bindings work at prerender time.prerenderEnvironment: "node"falls back to Astro's stock node prerenderer (+ the dev node-prerender middleware plugin) for pages that need Node-only APIs at build time. - Hardwired. The
passthroughimage service (see limitations). - Kept (vendored). The runtime entrypoints/handler/helpers (verified free
of wrangler/
@cloudflare/vite-pluginimports — enforced by the "vendored runtime purity" test), thevirtual:astro-cloudflare:configplugin (not exported upstream), theoptimizeDeps.includeenvironment plugin, thecf-imports/cf-externalsplugins, and theastro:build:setupserver tweaks (ssr.noExternal,sharpexternal, process-env banner). - Kept (build-output parity). The
astro:build:donepass mirrors upstream:_redirectsis generated from Astro's redirect routes (configredirects+ redirect route files, via@astrojs/underscore-redirects) so static/prerendered redirects are served by the asset layer ahead of the Worker;_headersgets an immutableCache-Control: public, max-age=31536000, immutablerule for the hashed_astro/assets directory (skipped when the user's_headersalready sets Cache-Control on a matching rule, or whenbuild.assetsPrefixpoints assets at another origin);injectTypesinstalls theApp.Localsruntime types (@distilled.cloud/astro/types.d.ts). - Kept (
base !== "/"). The client build is nested under the base so the assets directory serves at the URL root, the special files (.assetsignore/_headers/_redirects) are moved back up, and the target'sfinishpass pointsBuildOutput.clientDirectoryback at the original directory (the in-memory equivalent of upstream's emitted wrangler.jsonassets.directorypatch). - Sessions (zero-config). Mirrors upstream: with no configured
session.driver, Astro'scloudflareKVBindingdriver is configured against thesessionKVBindingNamebinding (SESSION); the vendored runtime handler injects the live KV binding from the Worker env on each request. In dev, an in-memoryKvNamespace.localnamespace is auto-added to the worker bindings soAstro.sessionworks with zero setup (disable viasessionDevKV: falsewhen your dev bindings already carry that binding). In production the Worker must be deployed with a KV binding namedsessionKVBindingName(with alchemy: give the Worker a KV namespace binding namedSESSION). astro:envat build time. The alchemy source provider (@distilled.cloud/astro/source) feeds the Worker's literal env values (strings,Redactedstrings, numbers/booleans) intoprocess.envbeforebuild/dev, soastro:envserver secrets resolve during config load and node prerendering — the role upstream'sloadWranglerEnv(wranglervars.dev.vars) plays.
- Typegen guard.
build/syncrun Astro's type generation, which boots a temporary vite server; the integration stripsconfigureServerfrom the cloudflare plugins in those phases (mirroring upstream) so workerd never boots mid-build — our dev environments degrade to runnable stubs, a supported contract of the dev plugin.
Build output
Framework.build returns the BuildOutput contract in-memory:
serverModules (entry first — the ssr entry chunk server/entry.mjs,
re-read from disk because Astro injects the serialized SSR manifest after the
bundler finishes) and clientDirectory (dist/client, captured as a path so
prerendered HTML written after the vite build rides along — including the
generated _redirects and _headers files). No wrangler.json, no
.wrangler/ directory, anywhere.
Limitations
- Node prerender fallback loses worker APIs. With
prerenderEnvironment: "node", prerendered routes (export const prerender = true) execute in Astro's stock node prerender environment: pages that importcloudflare:workers(or otherwise rely on worker-only APIs) at prerender time will fail to prerender there. The default"workerd"mode does not have this gap — prerendering runs in workerd with the worker's bindings attached. - Image service is passthrough. workerd cannot run sharp, and the
IMAGESbinding is remote-only in our runtime, so the integration hardwires Astro'spassthroughimage service: images are served as-is (no resizing / format negotiation). The production endpoint is the vendoredimage-passthrough-endpoint; dev uses Astro's generic node endpoint. - Sessions need a deployed KV binding in production. Zero-config sessions
work out of the box in dev (in-memory local KV) and configure the built app
to read the
sessionKVBindingNameKV binding from the Worker env — but provisioning that KV namespace on the deployed Worker is the deployment tool's job. Without it, session operations fail at runtime. astro previewis not used. The upstream preview entrypoint hard-depends on a wrangler deploy config; serving built output is the deploy target'sserveconcern (miniflare in the e2e harness; the alchemy dev loop uses cloudflare-runtime).- Version pinning. Astro's JS API is
@experimental, and the integration internals (virtual module names, adapter features) are versioned with Astro majors. The fork tracksastro7.x / adapter v14.1.3; treat version bumps as deliberate migrations backed by the fixture e2e suite.
Testing
bun run test— unit tests (test/Astro.test.ts: target module, config synthesis, integration hooks, wholesale-build delegation;test/parity.test.ts: sessions,_redirects/_headersgeneration,base !== "/"handling,injectTypes, the unsupported-option guard, env feeding;test/decoupling.test.ts: the framework half's cloudflare-free guarantee and runtime purity).fixtures/astro— the playwright e2e suite driving a real app through the harness in bothdev(workerd module runner) andlive(miniflare over the built output) modes: SSR + bindings, middleware locals/headers, config redirects (served from the generated_redirectsin live mode),Astro.sessionround-trips, immutable assetCache-Control, JSON/binary endpoints, content collections, prerendered pages, public assets, ASSETS 404 fallback. (Abase !== "/"fixture app is not yet part of the suite — the behavior is unit-tested intest/parity.test.ts.)
