| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { MemoOptions } from "../../Command/Memo.ts"; |
| 3 | import type { InputProps } from "../../Input.ts"; |
| 4 | import { effectClass } from "../../Util/effect.ts"; |
| 5 | import type { Providers } from "../Providers.ts"; |
| 6 | import type { AssetsConfig } from "../Workers/Assets.ts"; |
| 7 | import { |
| 8 | Worker, |
| 9 | type NormalizedBindings, |
| 10 | type WorkerAssetsConfig, |
| 11 | type WorkerBindingProps, |
| 12 | type WorkerProps, |
| 13 | } from "../Workers/Worker.ts"; |
| 14 | |
| 15 | /** |
| 16 | * The specifier of the Octane source-provider module. The package must be |
| 17 | * installed in the user's project — `loadSource` fails with a |
| 18 | * `SourceProviderError` naming it otherwise. |
| 19 | */ |
| 20 | const OCTANE_SOURCE_PROVIDER = "@alchemy.run/frontend-frameworks/octane/source"; |
| 21 | |
| 22 | export interface OctaneProps< |
| 23 | Bindings extends WorkerBindingProps = {}, |
| 24 | > extends Omit< |
| 25 | WorkerProps<Bindings>, |
| 26 | "vite" | "main" | "assets" | "source" | "script" | "bundle" |
| 27 | > { |
| 28 | /** |
| 29 | * Octane project root (the directory containing `vite.config.ts` and |
| 30 | * `octane.config.ts`). Relative paths resolve from the process working |
| 31 | * directory. |
| 32 | * @default process.cwd() |
| 33 | */ |
| 34 | rootDir?: string; |
| 35 | /** |
| 36 | * Controls which files are content-hashed to decide whether a rebuild is |
| 37 | * needed. By default every non-gitignored file under `rootDir` (plus the |
| 38 | * nearest package-manager lockfile) is hashed; narrow the scope with |
| 39 | * `include`/`exclude` globs when the project sits in a large repository. |
| 40 | */ |
| 41 | memo?: MemoOptions; |
| 42 | /** |
| 43 | * Optional configuration for static asset routing behavior. |
| 44 | * Supports `runWorkerFirst`, `htmlHandling`, `notFoundHandling`, etc. |
| 45 | * |
| 46 | * Octane's intended routing is asset-first with SSR on miss: exact files |
| 47 | * in `dist/client` serve without invoking the Worker, and every miss |
| 48 | * reaches Octane SSR. Leave `notFoundHandling` unset (`"none"`) — both |
| 49 | * `"single-page-application"` and `"404-page"` would prevent |
| 50 | * browser-navigation misses from reaching SSR. |
| 51 | */ |
| 52 | assets?: AssetsConfig; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * A Cloudflare Worker deployed from an [OctaneJS](https://octanejs.dev) |
| 57 | * fullstack project. |
| 58 | * |
| 59 | * Alchemy preserves Octane's native compiler and client build, builds the |
| 60 | * server for Workers, and generates the Worker entry. By default, |
| 61 | * `dist/server/worker.js` deploys as the Worker script and `dist/client` |
| 62 | * deploys as static assets; custom Octane `build.outDir` values are also |
| 63 | * supported. No hosting adapter, Wrangler configuration, or separate build |
| 64 | * command is required. |
| 65 | * |
| 66 | * Requires `@alchemy.run/frontend-frameworks` alongside `octane` and |
| 67 | * `@octanejs/vite-plugin` in your project. Keep the native `octane()` plugin, |
| 68 | * Tailwind, and other Vite plugins in `vite.config.ts`, and application |
| 69 | * routes and callbacks in `octane.config.ts`. These files load without |
| 70 | * being rewritten. Existing `adapter: cloudflare()` declarations from |
| 71 | * `@octanejs/adapter-cloudflare` remain supported but are optional; adapters |
| 72 | * for other hosting targets are rejected. |
| 73 | * |
| 74 | * Input files are content-hashed (respecting `.gitignore` by default) so |
| 75 | * unchanged projects skip the build and deploy entirely. |
| 76 | * |
| 77 | * Octane's server runtime needs synchronous SHA-256 and |
| 78 | * `AsyncLocalStorage`, so the `nodejs_compat` compatibility flag (enabled |
| 79 | * by default for every Worker) is required. |
| 80 | * |
| 81 | * A client-only Octane SPA (no `octane.config.ts` routes) is a plain Vite |
| 82 | * project — deploy it with {@link Vite | Cloudflare.Website.Vite} instead, |
| 83 | * where the `octane()` compiler plugin composes with the injected |
| 84 | * Cloudflare Vite plugin. |
| 85 | * |
| 86 | * |
| 87 | * ### Deploying an Octane App |
| 88 | * A single call builds and deploys the app — server-rendered routes, |
| 89 | * server (API) routes, and client assets included. Define the app's routes |
| 90 | * in `octane.config.ts`; `Cloudflare.Website.Octane` selects the Worker build: |
| 91 | * |
| 92 | * **Example:** octane.config.ts |
| 93 | * ```typescript |
| 94 | * import { defineConfig, RenderRoute } from "@octanejs/vite-plugin"; |
| 95 | * |
| 96 | * export default defineConfig({ |
| 97 | * router: { |
| 98 | * routes: [new RenderRoute({ path: "/", entry: ["App", "/src/App.tsx"] })], |
| 99 | * }, |
| 100 | * }); |
| 101 | * ``` |
| 102 | * |
| 103 | * **Example:** alchemy.run.ts |
| 104 | * ```typescript |
| 105 | * const site = yield* Cloudflare.Website.Octane("Website"); |
| 106 | * ``` |
| 107 | * |
| 108 | * **Example:** Octane project in a subdirectory |
| 109 | * ```typescript |
| 110 | * const site = yield* Cloudflare.Website.Octane("Website", { |
| 111 | * rootDir: "apps/web", |
| 112 | * }); |
| 113 | * ``` |
| 114 | * |
| 115 | * ### Bindings |
| 116 | * Values passed via `env` reach Octane middleware and `ServerRoute` |
| 117 | * handlers through `context.platform`. The generated Worker entry supplies |
| 118 | * the Cloudflare `{ env, ctx }` pair, so `platform.env.MY_KV` is the live |
| 119 | * binding and `platform.ctx.waitUntil` schedules background work. |
| 120 | * |
| 121 | * **Example:** Reading a binding from a ServerRoute |
| 122 | * ```typescript |
| 123 | * // octane.config.ts route |
| 124 | * // new ServerRoute({ |
| 125 | * // path: "/api/hello", |
| 126 | * // methods: ["GET"], |
| 127 | * // handler: (context) => { |
| 128 | * // const platform = context.platform as { env: { API_KEY: string } }; |
| 129 | * // return Response.json({ hasKey: platform.env.API_KEY !== undefined }); |
| 130 | * // }, |
| 131 | * // }) |
| 132 | * |
| 133 | * const site = yield* Cloudflare.Website.Octane("Website", { |
| 134 | * env: { |
| 135 | * API_KEY: Config.Redacted("API_KEY"), |
| 136 | * }, |
| 137 | * }); |
| 138 | * ``` |
| 139 | * |
| 140 | * **Example:** Binding a KV namespace |
| 141 | * ```typescript |
| 142 | * const cache = yield* Cloudflare.KV.Namespace("Cache"); |
| 143 | * |
| 144 | * const site = yield* Cloudflare.Website.Octane("Website", { |
| 145 | * env: { |
| 146 | * CACHE: cache, |
| 147 | * }, |
| 148 | * }); |
| 149 | * ``` |
| 150 | * |
| 151 | * ### Dev |
| 152 | * `alchemy dev` runs Octane's own Vite dev server (the plugin's in-process |
| 153 | * SSR middleware — rendering, server routes, and RPC with full HMR). |
| 154 | * NOTE: Octane's dev middleware does not supply request-scoped platform |
| 155 | * bindings (`context.platform` is `undefined` in dev — an upstream |
| 156 | * limitation), so code touching `platform.env` must tolerate `undefined` |
| 157 | * during dev; bindings are live in deployed Workers. |
| 158 | * |
| 159 | * ### Custom Rebuild Scope |
| 160 | * By default, every non-gitignored file is hashed to decide whether a |
| 161 | * rebuild is needed. Use `memo` to narrow the scope when the project |
| 162 | * lives in a large repository. |
| 163 | * |
| 164 | * **Example:** Narrowing the memo scope |
| 165 | * ```typescript |
| 166 | * const site = yield* Cloudflare.Website.Octane("Website", { |
| 167 | * memo: { |
| 168 | * include: ["src/**", "public/**", "octane.config.ts", "vite.config.ts", "package.json"], |
| 169 | * }, |
| 170 | * }); |
| 171 | * ``` |
| 172 | * |
| 173 | * ### Class Form |
| 174 | * Calling `Octane` with no arguments returns a constructor you can |
| 175 | * `extend` to declare the Worker as a named class. The class is both an |
| 176 | * `Effect` you can `yield*` to deploy and a type you can reference |
| 177 | * elsewhere — useful when other resources need to bind to this Worker. |
| 178 | * |
| 179 | * **Example:** Declaring a Worker class |
| 180 | * ```typescript |
| 181 | * class Website extends Cloudflare.Website.Octane<Website>()( |
| 182 | * "Website", |
| 183 | * ) {} |
| 184 | * |
| 185 | * const site = yield* Website; |
| 186 | * ``` |
| 187 | * |
| 188 | * @resource |
| 189 | * @product Website |
| 190 | * @category Workers & Compute |
| 191 | */ |
| 192 | export const Octane: { |
| 193 | <Self>(): { |
| 194 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 195 | id: string, |
| 196 | propsEff?: |
| 197 | | InputProps<OctaneProps<Bindings>> |
| 198 | | Effect.Effect<InputProps<OctaneProps<Bindings>>, never, Req>, |
| 199 | ): Effect.Effect<Self, never, Req | Providers> & { |
| 200 | new (): Worker<{ |
| 201 | [ |
| 202 | binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig> |
| 203 | ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding]; |
| 204 | }>; |
| 205 | }; |
| 206 | }; |
| 207 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 208 | id: string, |
| 209 | propsEff?: |
| 210 | | InputProps<OctaneProps<Bindings>> |
| 211 | | Effect.Effect<InputProps<OctaneProps<Bindings>>, never, Req>, |
| 212 | ): Effect.Effect< |
| 213 | Worker<{ |
| 214 | [ |
| 215 | binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig> |
| 216 | ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding]; |
| 217 | }>, |
| 218 | never, |
| 219 | Req | Providers |
| 220 | >; |
| 221 | } = (<const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 222 | id?: string, |
| 223 | propsEff?: |
| 224 | | InputProps<OctaneProps<Bindings>> |
| 225 | | Effect.Effect<InputProps<OctaneProps<Bindings>>, never, Req>, |
| 226 | ) => |
| 227 | id === undefined |
| 228 | ? <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 229 | id: string, |
| 230 | propsEff?: |
| 231 | | InputProps<OctaneProps<Bindings>> |
| 232 | | Effect.Effect<InputProps<OctaneProps<Bindings>>, never, Req>, |
| 233 | ) => effectClass(Octane(id, propsEff)) |
| 234 | : Worker( |
| 235 | id, |
| 236 | Effect.map( |
| 237 | Effect.isEffect(propsEff) ? propsEff : Effect.succeed(propsEff), |
| 238 | (props) => ({ |
| 239 | ...props, |
| 240 | // Octane's server bundle externalizes `node:` modules for |
| 241 | // workerd's native node-compat — `getCompatibility` already |
| 242 | // adds `nodejs_compat` to every non-python Worker. |
| 243 | main: undefined!, |
| 244 | source: { |
| 245 | provider: OCTANE_SOURCE_PROVIDER, |
| 246 | devMode: "server", |
| 247 | rootDir: props?.rootDir, |
| 248 | options: { |
| 249 | rootDir: props?.rootDir, |
| 250 | memo: props?.memo, |
| 251 | }, |
| 252 | }, |
| 253 | }), |
| 254 | ), |
| 255 | )) as any; |