| 1 | # @alchemy.run/frontend-frameworks/core |
| 2 | |
| 3 | Platform-neutral core for framework integrations (Vite, Waku, Astro, SvelteKit, Next.js): |
| 4 | |
| 5 | - **`BuildOutput` contract** — `{ clientDirectory, serverModules (entry first, sha256-hashed), externalWorkspaces }`, plus standalone persistence helpers (`writeBuildOutput` / `readBuildOutput`) used by the e2e harness for its `dist/build.json` (persistence is a harness/testing concern, not part of the `Framework` contract). |
| 6 | - **Build-output collector** — the `alchemy:build-output` Vite plugin (`makeBuildOutputCollector`): captures client/server outputs across environments, with `skipEnvironments` (e.g. Astro's `prerender`), deterministic server-entry selection (pins the wrapped `\0distilled:worker-entry:` main), and a post-`buildApp` disk re-read mode (`collect({ fromDisk: true })`) for frameworks that write or prune server modules after the bundler finishes (e.g. Waku). |
| 7 | - **`readServerModulesFromDisk`** — for frameworks whose final server bundle lives on disk (SvelteKit's rolldown pass, Next.js's `.open-next` output). |
| 8 | - **`loadProjectModule`** — load the _project's_ `vite`/framework install instead of ours. |
| 9 | - **`Framework` service contract** — the common effectful `{ build, dev }` service each framework package implements; `build` returns the `BuildOutput` purely in-memory. |
| 10 | - **`DeployTarget` contract** — the deploy target as a _value_ passed to framework integrations, with Cloudflare Workers as the first implementation. See the architecture section below. |
| 11 | |
| 12 | ## Architecture: frameworks × deploy targets |
| 13 | |
| 14 | A framework integration answers "how do I drive this framework's build and dev |
| 15 | servers programmatically?". A **deploy target** answers "what platform is the |
| 16 | server code being produced _for_?". These are orthogonal, and the package |
| 17 | layout reflects it: |
| 18 | |
| 19 | - **framework-core** (this package) is platform-neutral: `BuildOutput`, the |
| 20 | collector, the loaders, the `Framework` service, and the generic |
| 21 | `DeployTarget` seams below. It never imports a platform SDK or bundler |
| 22 | plugin for a specific runtime. |
| 23 | - **Framework packages** (`@alchemy.run/frontend-frameworks/waku`, `.../astro`, |
| 24 | `.../sveltekit`, and later `.../nextjs`) own the framework mechanics — |
| 25 | programmatic build/dev entry points, config synthesis, adapter plumbing — |
| 26 | and take the deploy target as a **prop** (`target:`) rather than hardcoding |
| 27 | Cloudflare. |
| 28 | - **Per-framework target modules** own the halves that are neither generic |
| 29 | nor platform-neutral: waku's cloudflare adapter fork, astro's integration |
| 30 | fork, kit's in-memory adapter + post-adapt re-bundle. Each ships as a |
| 31 | subpath of its framework package (e.g. `@alchemy.run/frontend-frameworks/waku/cloudflare` |
| 32 | exporting the `WakuCloudflareTarget`). A future AWS target is a new subpath |
| 33 | (`@alchemy.run/frontend-frameworks/waku/aws`) implementing the same seams — no change to |
| 34 | the framework package or to framework-core. |
| 35 | |
| 36 | ### The `DeployTarget` interface |
| 37 | |
| 38 | ```ts |
| 39 | interface DeployTarget<Config = unknown> { |
| 40 | /** Stable platform identifier: "cloudflare", "aws", ... */ |
| 41 | readonly platform: string; |
| 42 | /** Opaque target configuration — never inspected by framework-core. |
| 43 | * Cloudflare: worker name/bindings/compatibility; AWS later: its own shape. */ |
| 44 | readonly config: Config; |
| 45 | /** The USER's own server entry, when the target config carries one: a module |
| 46 | * that wraps the framework's emitted entry (Cloudflare: a worker entry |
| 47 | * re-exporting Durable Object classes). `{ main: string }`, relative to the |
| 48 | * project root or absolute — resolve via `resolveDeployTargetEntry`. */ |
| 49 | readonly entry?: DeployTargetEntry; |
| 50 | /** Resolve/bundle settings for server code: conditions (e.g. ["workerd", |
| 51 | * "worker", "module", "browser"]), externals (e.g. ["cloudflare:"]), mainFields. */ |
| 52 | readonly bundle?: DeployTargetBundleOptions; |
| 53 | /** WHOLESALE build takeover: when defined, the framework package delegates |
| 54 | * its entire production build here (the OpenNext case for Next.js). |
| 55 | * Most targets leave this undefined and let the framework drive. */ |
| 56 | readonly build?: ( |
| 57 | context: DeployTargetBuildContext, |
| 58 | ) => Effect<BuildOutput, DeployTargetError, DeployTargetServices>; |
| 59 | /** Finishing pass over the framework-produced BuildOutput — the |
| 60 | * SvelteKit-style post-adapt re-bundle for the target runtime. |
| 61 | * `context.entry` carries the on-disk server entry when the framework's |
| 62 | * adapt step wrote one. Run via `applyDeployTargetFinish`. */ |
| 63 | readonly finish?: ( |
| 64 | output: BuildOutput, |
| 65 | context: DeployTargetFinishContext, |
| 66 | ) => Effect<BuildOutput, DeployTargetError, DeployTargetServices>; |
| 67 | /** The target's local serving story for BUILT server code (preview-parity: |
| 68 | * miniflare / cloudflare-runtime today; a Lambda emulator later). |
| 69 | * Scoped — closing the Scope stops the server. Returns { url, fetch? }. */ |
| 70 | readonly serve?: ( |
| 71 | context: DeployTargetServeContext, |
| 72 | ) => Effect<DeployTargetServer, DeployTargetError, Scope | DeployTargetServices>; |
| 73 | } |
| 74 | ``` |
| 75 | |
| 76 | (`DeployTargetServices = FileSystem | Path` — the same services the harness |
| 77 | provides to `Framework` layers.) |
| 78 | |
| 79 | Only these seams are generic. Everything else a platform needs — the vite |
| 80 | plugin to inject, the adapter to select, dev-server integration that reaches |
| 81 | inside a framework's toolchain — belongs to the per-framework target module, |
| 82 | which **extends** `DeployTarget` with framework-specific hooks: |
| 83 | |
| 84 | ```ts |
| 85 | // packages/frontend-frameworks/src/waku/cloudflare.ts (illustrative) |
| 86 | export interface WakuTarget extends DeployTarget<WakuCloudflareConfig> { |
| 87 | /** Absolute path of the adapter module waku's config should select. */ |
| 88 | readonly adapterPath: Effect<string, DeployTargetError>; |
| 89 | /** Vite plugins to inject into waku's `vite.plugins` (dev + build). */ |
| 90 | readonly vitePlugins: (context: ...) => Effect<PluginOption[], ...>; |
| 91 | } |
| 92 | export default (config: WakuCloudflareConfig): WakuTarget => ...; |
| 93 | ``` |
| 94 | |
| 95 | Helpers exported by framework-core: |
| 96 | |
| 97 | - `makeDeployTarget(target)` — identity with contract checking; preserves |
| 98 | framework-specific extensions. |
| 99 | - `isDeployTarget(value)` — structural guard (`platform: string` + `config`). |
| 100 | - `resolveDeployTarget(root, input, config)` — resolves a |
| 101 | `DeployTargetInput`: a target value (as-is), a factory |
| 102 | (`(config) => target`, applied to `config`), or a module specifier string |
| 103 | (loaded from the _project's_ `node_modules` via `loadProjectModule`; the |
| 104 | module's `default` — or named `target` — export is the value or factory). |
| 105 | - `applyDeployTargetFinish(target, output, context)` — run the finishing pass |
| 106 | if defined, else pass the build through. |
| 107 | - `resolveDeployTargetEntry(target, { root })` — the user-entry seam's |
| 108 | accessor: the absolute path of `target.entry.main` (resolved against the |
| 109 | project root), or `undefined` when no user entry is configured. |
| 110 | - `selectEntryByFacade(entryPath)` (collector) — a `selectEntry` predicate |
| 111 | pinning the chunk whose facade module is that entry file (tolerates the |
| 112 | `\0distilled:worker-entry:` wrapper id). |
| 113 | |
| 114 | ### The user-entry seam (`target.entry`) |
| 115 | |
| 116 | Some apps need their **own module** to be the deployed entry instead of the |
| 117 | framework's — on Cloudflare, a worker entry that wraps the framework's fetch |
| 118 | handler and additionally exports Durable Object / Workflow classes (they must |
| 119 | live on the same worker for their bindings to resolve). The seam is designed |
| 120 | once, cross-framework: |
| 121 | |
| 122 | 1. **Carriage.** The target's config carries the raw value in its own shape |
| 123 | (Cloudflare: the vite plugin's `main` option — so the built-in Vite path |
| 124 | honors it natively). The target surfaces it platform-neutrally as |
| 125 | `entry: { main }`; framework packages read it via |
| 126 | `resolveDeployTargetEntry(target, { root })`. |
| 127 | 2. **Bundler entry.** The framework package makes the user entry the entry |
| 128 | module its build/dev pipeline serves, instead of unconditionally pinning |
| 129 | the framework's own emitted entry (waku: |
| 130 | `makeWakuPluginOptions` honors the user main; dev's module runner serves |
| 131 | the wrapped entry, so DO classes exist in dev too). |
| 132 | 3. **Wrappable handler.** The framework package exposes a stable importable |
| 133 | specifier for its emitted server handler so the user entry has something |
| 134 | to wrap. Convention: `virtual:{framework}/server-entry` (precedent: React |
| 135 | Router's `virtual:react-router/server-build`; waku ships |
| 136 | `virtual:waku/server-entry`). |
| 137 | 4. **Entry selection.** The chunk built from the user entry becomes |
| 138 | `serverModules[0]` (`selectEntryByFacade`); the framework's own entry |
| 139 | remains an ordinary chunk the user entry imports. |
| 140 | |
| 141 | ### How a framework package consumes the target |
| 142 | |
| 143 | 1. Its options gain `target?: <Framework>TargetInput` (a |
| 144 | `DeployTargetInput<<Framework>Target, Config>`), defaulting to its own |
| 145 | cloudflare subpath specifier (e.g. `"@alchemy.run/frontend-frameworks/waku/cloudflare"`). |
| 146 | Resolve it once per operation with |
| 147 | `resolveDeployTarget(root, input, config)` where `config` is the |
| 148 | target-scoped configuration the caller passed. |
| 149 | 2. `Framework.build`: |
| 150 | - if `target.build` is defined, delegate wholesale (OpenNext). |
| 151 | - otherwise run the framework build, consulting the target's |
| 152 | framework-specific hooks (plugin/adapter injection) and `target.bundle` |
| 153 | where the framework package does its own resolving/bundling, then finish |
| 154 | with `applyDeployTargetFinish(target, output, { root, framework, entry })`. |
| 155 | 3. `Framework.dev`: the HMR dev server is framework-owned; target-specific |
| 156 | dev integration (the workerd module-runner vite plugin, dev `platform` |
| 157 | stubs) comes from the per-framework target hooks. `target.serve` is NOT |
| 158 | the HMR path — it serves _built_ output (preview parity). |
| 159 | 4. Never import a platform package (`@alchemy.run/cloudflare-runtime/vite`, |
| 160 | `cloudflare-runtime`, a future AWS SDK) from the framework package's core |
| 161 | modules — only from its target subpath module. |
| 162 | |
| 163 | ### Harness carriage (`e2e.config.ts`) |
| 164 | |
| 165 | The e2e harness carries target configuration target-scoped: |
| 166 | |
| 167 | ```ts |
| 168 | export default Options.make({ |
| 169 | target: { |
| 170 | // name: "cloudflare" is the default (the only implemented target today) |
| 171 | cloudflare: { |
| 172 | worker: { |
| 173 | /* CloudflareVitePluginOptions: compat date/flags, name, bindings, assets */ |
| 174 | }, |
| 175 | preview: { |
| 176 | /* miniflare options for `e2e preview` / Playwright "live" */ |
| 177 | }, |
| 178 | }, |
| 179 | }, |
| 180 | framework: "@alchemy.run/frontend-frameworks/waku", |
| 181 | }); |
| 182 | ``` |
| 183 | |
| 184 | The pre-target top-level fields remain as deprecated aliases |
| 185 | (`vite` ≙ `target.cloudflare.worker`, `miniflare` ≙ |
| 186 | `target.cloudflare.preview`); `Options.resolveCloudflareOptions(options)` is |
| 187 | the single accessor that merges them (target-scoped wins). Framework packages |
| 188 | that read harness options structurally must read |
| 189 | `options.target?.cloudflare?.worker ?? options.vite`. The harness's miniflare |
| 190 | preview is exposed as the cloudflare target's `serve` |
| 191 | (`packages/cloudflare-test-tools/src/e2e/CloudflareTarget.ts`) — serving built output is the |
| 192 | target's concern; the implementation lives in the harness while miniflare is |
| 193 | a harness dependency. |
| 194 | |
| 195 | ### Migration recipe per framework |
| 196 | |
| 197 | Common steps: add `target?:` to the framework options (default: the package's |
| 198 | `./cloudflare` subpath), create `src/<framework>/cloudflare.ts` + a `./cloudflare` export |
| 199 | map entry, move every cloudflare import out of the core module into it, and |
| 200 | read harness options via the target-scoped carriage above. |
| 201 | |
| 202 | - **Waku** — `WakuTarget` extends `DeployTarget` with the adapter-path hook |
| 203 | (today: the wrangler-free fork at `@alchemy.run/frontend-frameworks/waku/adapter`, selected |
| 204 | via `unstable_adapter`) and the vite-plugin hook (today: |
| 205 | `cloudflareVitePlugin(makeWakuPluginOptions(...))` with the pinned |
| 206 | rsc/ssr topology). `Waku.ts` keeps: config synthesis, the SSG |
| 207 | preview-server global, the collector, dev/build orchestration. |
| 208 | - **Astro** — `AstroTarget` supplies the integration (today: the |
| 209 | `@astrojs/cloudflare` fork over our vite plugin, with its server |
| 210 | entrypoints) injected into `AstroInlineConfig`; `Astro.ts` keeps the |
| 211 | programmatic `dev()`/`build()` driving and the collector |
| 212 | (`entryEnvironment: "ssr"`, `skipEnvironments: ["prerender"]`). |
| 213 | - **SvelteKit** — `SvelteKitTarget` supplies the kit `Adapter` instance |
| 214 | (today: the in-memory cloudflare adapter fork) and implements the generic |
| 215 | `finish` seam (today: the rolldown re-bundle of `_worker.js` for workerd, |
| 216 | using `target.bundle` conditions); `SvelteKit.ts` keeps kit config |
| 217 | synthesis, `buildApp` driving, and the Node-SSR dev server (the dev |
| 218 | `platform` emulation is a target hook). |
| 219 | - **Next.js** (separate branch; adopts this interface) — the OpenNext |
| 220 | pipeline is the canonical `target.build` wholesale takeover: the |
| 221 | cloudflare target owns the OpenNext orchestration, the final bundle pass, |
| 222 | and dev v1 serving via cloudflare-runtime; the framework package is a thin |
| 223 | shell that resolves the target and delegates. |
| 224 | |
| 225 | ### Doctrine (applies to every target implementation) |
| 226 | |
| 227 | - **Wrangler-free, programmatic-only.** No `wrangler.json` is read or |
| 228 | written anywhere; every piece of configuration a wrangler file would carry |
| 229 | is expressed as in-memory options on the target's `config`. We never spawn |
| 230 | a framework's CLI binary (upstream pipelines may internally — e.g. OpenNext |
| 231 | spawning `next build` — but that is upstream orchestration, not ours). |
| 232 | - **Platform-proxy policy.** Wherever an upstream integration reaches for |
| 233 | wrangler's `getPlatformProxy` (SvelteKit `adapter-cloudflare`, OpenNext |
| 234 | `initOpenNextCloudflareForDev`, Astro `platformProxy`), the replacement is |
| 235 | `@alchemy.run/cloudflare-runtime/core/platform-proxy` (workerd-backed |
| 236 | Node-side proxies for `env`/`cf`/`ctx`/`caches`, configured in-memory) — |
| 237 | never a wrangler dependency. SvelteKit's `makeDevPlatformEmulator` is the |
| 238 | first consumer. |
| 239 | - **Version pinning.** Upstream surfaces the integrations touch are |
| 240 | `@experimental`/`unstable_`/unexported: pin exact framework versions, |
| 241 | e2e-test against real apps in CI, and treat version bumps as deliberate |
| 242 | migrations. |