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 type { Providers } from "../Providers.ts";
6import type { AssetsConfig } from "../Workers/Assets.ts";
7import {
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 */
20const OCTANE_SOURCE_PROVIDER = "@alchemy.run/frontend-frameworks/octane/source";
21
22export 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 */
192export 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;