1import * as FrameworkCore from "../core/index.ts";
2import {
3 Framework,
4 FrameworkError,
5 type DeployTarget,
6 type DeployTargetInput,
7} from "../core/index.ts";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12import * as Schedule from "effect/Schedule";
13
14/**
15 * The structural slice of the project's `@octanejs/vite-plugin` module this
16 * package drives. Typed structurally so the package carries no dependency on
17 * `@octanejs/vite-plugin` — the *project's* install is always the one loaded.
18 */
19export interface OctaneVitePluginModule {
20 /**
21 * Load and resolve the project's `octane.config.ts` (spins a temporary
22 * Vite module runner internally when no dev server is supplied, so `.tsrx`
23 * imports in the config graph resolve).
24 */
25 readonly loadOctaneConfig: (
26 projectRoot: string,
27 options?: Record<string, unknown>,
28 ) => Promise<ResolvedOctaneConfigSlice | null>;
29}
30
31/** The structural slice of a resolved `octane.config.ts` this package reads. */
32export interface ResolvedOctaneConfigSlice {
33 readonly router: { readonly routes: ReadonlyArray<unknown> };
34 readonly build: { readonly outDir: string };
35 readonly adapter?:
36 | {
37 readonly name?: string | undefined;
38 readonly serverTarget?: string | undefined;
39 }
40 | undefined;
41}
42
43/** The structural slice of the project's `vite` module this package drives. */
44export interface OctaneViteModule {
45 readonly version?: string;
46 readonly build: (config: Record<string, unknown>) => Promise<unknown>;
47 readonly createServer: (
48 config: Record<string, unknown>,
49 ) => Promise<OctaneViteDevServer>;
50}
51
52/** The structural slice of a Vite dev server this package reads. */
53export interface OctaneViteDevServer {
54 readonly listen: () => Promise<unknown>;
55 readonly close: () => Promise<void>;
56 readonly resolvedUrls?:
57 | { readonly local: ReadonlyArray<string> }
58 | null
59 | undefined;
60}
61
62/**
63 * The configuration this package assembles from {@link OctaneOptions} and
64 * hands to a deploy-target factory. The target treats it as its
65 * `DeployTarget.config`; the framework half never inspects a resolved
66 * target's config.
67 */
68export interface OctaneTargetConfig {
69 /** Compatibility date the target carries for serve/deploy. */
70 readonly compatibilityDate?: string | undefined;
71 /** Compatibility flags the target carries for serve/deploy. */
72 readonly compatibilityFlags?: Array<string> | undefined;
73}
74
75/**
76 * A deploy target for Octane: the generic `DeployTarget` seams plus the one
77 * framework-specific requirements for native output and legacy adapters.
78 * Node and AWS targets wrap Octane's default Node output. Cloudflare still
79 * requires its native adapter in the project's `octane.config.ts`.
80 */
81export interface OctaneTarget extends DeployTarget<OctaneTargetConfig> {
82 /**
83 * The `adapter.name` the resolved `octane.config.ts` must carry (the
84 * Cloudflare target requires `"cloudflare"` — Octane's
85 * `@octanejs/adapter-cloudflare`).
86 */
87 readonly adapterName: string;
88 /**
89 * The npm package providing that adapter — used in the actionable error
90 * when the project's config selects no (or a foreign) adapter.
91 */
92 readonly adapterPackage: string;
93 /** Whether this target needs an application-selected adapter rather than native Node output. */
94 readonly requiresAdapter?: boolean;
95 /**
96 * The Worker entry module the adapter emits, relative to the build's
97 * server output directory (Cloudflare: `worker.js`).
98 */
99 readonly serverEntryFileName: string;
100}
101
102/**
103 * How a deploy target is passed to this package: an `OctaneTarget` value, a
104 * factory `(config) => OctaneTarget`, or a module specifier resolved from the
105 * *project's* `node_modules` (default-export — or named export `target` — a
106 * value or factory).
107 */
108export type OctaneTargetInput = DeployTargetInput<
109 OctaneTarget,
110 OctaneTargetConfig
111>;
112
113/**
114 * The default deploy target: this package's own Cloudflare Workers target
115 * module (`src/cloudflare.ts`), loaded from the project's dependency tree.
116 */
117export const DEFAULT_TARGET_SPECIFIER =
118 "@alchemy.run/frontend-frameworks/octane/cloudflare";
119
120/** The specifier the project's Octane Vite integration is loaded from. */
121export const OCTANE_VITE_PLUGIN_SPECIFIER = "@octanejs/vite-plugin";
122
123export interface OctaneOptions {
124 /**
125 * Project root (the directory containing `vite.config.ts` and
126 * `octane.config.ts`).
127 * @default process.cwd()
128 */
129 readonly root?: string | undefined;
130 /**
131 * The deploy target the build is produced for. Accepts a target value, a
132 * `(config) => target` factory, or a module specifier string.
133 * @default "@alchemy.run/frontend-frameworks/octane/cloudflare"
134 */
135 readonly target?: OctaneTargetInput | undefined;
136 /** Compatibility date forwarded to the target via its config. */
137 readonly compatibilityDate?: string | undefined;
138 /**
139 * Compatibility flags forwarded to the target via its config. Octane's
140 * server runtime needs synchronous SHA-256 and `AsyncLocalStorage`, so on
141 * workerd-style targets `nodejs_compat` is effectively required.
142 */
143 readonly compatibilityFlags?: Array<string> | undefined;
144 readonly dev?:
145 | {
146 /** Default dev-server port (overridden by `FrameworkDevOptions.port`). */
147 readonly port?: number | undefined;
148 }
149 | undefined;
150}
151
152const fail = (message: string, cause?: unknown) =>
153 new FrameworkError({ framework: "octane", message, cause });
154
155/**
156 * Build the `Framework` service implementation for an Octane project.
157 *
158 * - `build` resolves the deploy target, then drives the PROJECT's own Vite
159 * install programmatically: Octane's `vite build` is the whole pipeline —
160 * `@octanejs/vite-plugin` builds the client bundle, launches the `ssr:
161 * true` server sub-build, and runs the config's deploy adapter, which
162 * emits the platform Worker entry (Cloudflare:
163 * `dist/server/worker.js`, self-contained ESM with only `node:`
164 * externals). The `BuildOutput` is then read from disk: `serverModules`
165 * entry-first from `<outDir>/server`, `clientDirectory` =
166 * `<outDir>/client`. Node and AWS accept adapterless native output;
167 * Cloudflare requires `adapter: cloudflare()`. Explicit incompatible
168 * adapters fail with an actionable error.
169 * - `dev` runs Octane's own Vite dev server programmatically (the plugin's
170 * dev SSR middleware serves rendering, server routes, and RPC in-process),
171 * scoped — closing the Scope closes the server. NOTE: Octane's dev
172 * middleware does not supply request-scoped platform bindings
173 * (`context.platform` is `undefined` in dev — an upstream limitation), so
174 * Cloudflare bindings are live/preview-only for now.
175 *
176 * This module is target-agnostic: everything Cloudflare-specific lives in
177 * `@alchemy.run/frontend-frameworks/octane/cloudflare`.
178 */
179export const make: (
180 options?: OctaneOptions,
181) => Effect.Effect<
182 Framework["Service"],
183 never,
184 FileSystem.FileSystem | Path.Path
185> = Effect.fnUntraced(function* (options?: OctaneOptions) {
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const baseRoot = options?.root ?? (yield* Effect.sync(() => process.cwd()));
189
190 const targetConfig: OctaneTargetConfig = {
191 compatibilityDate: options?.compatibilityDate,
192 compatibilityFlags: options?.compatibilityFlags,
193 };
194
195 const resolveTarget = (root: string) =>
196 FrameworkCore.resolveDeployTarget<OctaneTarget, OctaneTargetConfig>(
197 root,
198 options?.target ?? DEFAULT_TARGET_SPECIFIER,
199 targetConfig,
200 ).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
201
202 const loadVite = (root: string) =>
203 FrameworkCore.loadProjectModule<OctaneViteModule>(root, "vite").pipe(
204 Effect.mapError((error) =>
205 fail("Failed to load the project's Vite install", error.cause),
206 ),
207 );
208
209 const loadOctanePlugin = (root: string) =>
210 FrameworkCore.loadProjectModule<OctaneVitePluginModule>(
211 root,
212 OCTANE_VITE_PLUGIN_SPECIFIER,
213 ).pipe(
214 Effect.mapError((error) =>
215 fail(
216 `Failed to load the project's "${OCTANE_VITE_PLUGIN_SPECIFIER}" install — ` +
217 "is it added to the project's dependencies?",
218 error.cause,
219 ),
220 ),
221 );
222
223 const build: Framework["Service"]["build"] = Effect.fn(
224 function* (buildOptions) {
225 const root = buildOptions?.root ?? baseRoot;
226 const target = yield* resolveTarget(root);
227 const targetContext = {
228 root,
229 framework: "octane",
230 env: buildOptions?.env,
231 };
232
233 // Wholesale build takeover (targets that own the entire pipeline).
234 if (target.build !== undefined) {
235 return yield* target.build(targetContext).pipe(
236 Effect.mapError((error) => fail(error.message, error.cause)),
237 Effect.provideService(FileSystem.FileSystem, fs),
238 Effect.provideService(Path.Path, path),
239 );
240 }
241
242 const octanePlugin = yield* loadOctanePlugin(root);
243 const config = yield* Effect.tryPromise({
244 try: async () => await octanePlugin.loadOctaneConfig(root),
245 catch: (error) =>
246 fail("Failed to load the project's octane.config.ts", error),
247 });
248 if (config === null || config.router.routes.length === 0) {
249 return yield* Effect.fail(
250 fail(
251 "The project has no octane.config.ts routes. A fullstack Octane app " +
252 "(routes + SSR) is required here; a client-only Octane SPA deploys " +
253 "through the plain Vite integration instead.",
254 ),
255 );
256 }
257 if (
258 (config.adapter !== undefined || target.requiresAdapter) &&
259 config.adapter?.name !== target.adapterName
260 ) {
261 return yield* Effect.fail(
262 fail(
263 config.adapter?.name === undefined
264 ? `octane.config.ts selects no deploy adapter. Add the "${target.platform}" ` +
265 `adapter from ${target.adapterPackage}, e.g. ` +
266 `\`import { ${target.adapterName} } from "${target.adapterPackage}"\` ` +
267 `and \`adapter: ${target.adapterName}()\`.`
268 : `octane.config.ts selects the "${config.adapter.name}" deploy adapter, but ` +
269 `this build targets "${target.platform}" — use the "${target.adapterName}" ` +
270 `adapter from ${target.adapterPackage} instead.`,
271 ),
272 );
273 }
274
275 // Octane's plugin (from the project's own vite.config.ts) drives the
276 // entire production pipeline inside this one call: client build,
277 // `ssr: true` server sub-build, and the adapter's adapt() pass.
278 const vite = yield* loadVite(root);
279 yield* Effect.tryPromise({
280 try: async () => await vite.build({ root, logLevel: "warn" }),
281 catch: (error) => fail("Failed to build", error),
282 });
283
284 const outDir = path.resolve(root, config.build.outDir);
285 const output = yield* readOctaneOutput({
286 dir: outDir,
287 serverDir: path.join(outDir, "server"),
288 clientDir: path.join(outDir, "client"),
289 serverEntryFileName: target.serverEntryFileName,
290 }).pipe(Effect.provideService(FileSystem.FileSystem, fs));
291
292 // The finishing-pass seam (none needed for Cloudflare — the adapter's
293 // worker.js is already self-contained workerd ESM — but the contract
294 // is honored for targets that do post-process).
295 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
296 ...targetContext,
297 entry: path.join(outDir, "server", target.serverEntryFileName),
298 }).pipe(
299 Effect.mapError((error) => fail(error.message, error.cause)),
300 Effect.provideService(FileSystem.FileSystem, fs),
301 Effect.provideService(Path.Path, path),
302 );
303 },
304 );
305
306 const dev: Framework["Service"]["dev"] = Effect.fn(function* (devOptions) {
307 const root = devOptions?.root ?? baseRoot;
308 const vite = yield* loadVite(root);
309 // `port: 0` (true OS-assigned) on Vite >= 8.2.1, probed ephemeral port
310 // on older Vite — see `resolveViteDevPort`.
311 const port = yield* FrameworkCore.resolveViteDevPort(
312 vite.version,
313 devOptions?.port ?? options?.dev?.port,
314 );
315 const host = devOptions?.host;
316
317 const server = yield* Effect.acquireRelease(
318 Effect.tryPromise({
319 try: async () => {
320 const server = await vite.createServer({
321 root,
322 server: {
323 port,
324 ...(host !== undefined ? { host } : undefined),
325 },
326 });
327 await server.listen();
328 return server;
329 },
330 catch: (error) => fail("Failed to start the Octane dev server", error),
331 }),
332 (server) =>
333 Effect.promise(async () => {
334 try {
335 await server.close();
336 } catch {
337 // teardown is best-effort
338 }
339 }),
340 );
341
342 const url = server.resolvedUrls?.local[0];
343 if (url === undefined) {
344 return yield* Effect.fail(fail("Could not determine the dev server URL"));
345 }
346
347 // Bounded readiness probe: any HTTP response counts (vite serves
348 // lazily; we only need the listener to answer).
349 yield* Effect.tryPromise({
350 try: async () => {
351 const response = await fetch(url, { redirect: "manual" });
352 await response.arrayBuffer().catch(() => {});
353 },
354 catch: (error) => fail("The dev server did not become reachable", error),
355 }).pipe(
356 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 40 }),
357 );
358
359 return { url };
360 });
361
362 return Framework.of({ build, dev });
363});
364
365/**
366 * Build-output files under `<outDir>/server` that are inputs to the
367 * adapter's adapt() pass rather than runtime Worker modules: the SSR HTML
368 * template and Rsbuild's late client asset map are both EMBEDDED into the
369 * generated `worker.js`, so uploading them as Worker modules would be
370 * redundant (and `.html` is not a valid Worker module type).
371 */
372const NON_MODULE_SERVER_FILES = new Set([
373 "index.html",
374 "octane-client-assets.json",
375]);
376
377/** The resolved Octane build output directories. */
378export interface OctaneOutputDirs {
379 /** Root output directory (`<root>/dist`). */
380 readonly dir: string;
381 /** Server bundle directory (`<root>/dist/server`). */
382 readonly serverDir: string;
383 /** Static assets directory (`<root>/dist/client`). */
384 readonly clientDir: string;
385 /**
386 * The adapter-emitted Worker entry file name within `serverDir`.
387 * @default "worker.js"
388 */
389 readonly serverEntryFileName?: string | undefined;
390}
391
392/**
393 * Map Octane's on-disk `dist` onto the `BuildOutput` contract:
394 * `serverModules` entry-first from the server directory (POSIX names,
395 * sha256-hashed, excluding the embedded-template inputs), `clientDirectory`
396 * = the client directory. The adapter's server output is already
397 * self-contained ESM (only `node:` externals) — no finishing pass, and no
398 * external workspace roots (the server sub-build bundles with
399 * `noExternal: true`, so workspace imports are inlined).
400 */
401export const readOctaneOutput = (
402 dirs: OctaneOutputDirs,
403): Effect.Effect<
404 FrameworkCore.BuildOutput,
405 FrameworkError,
406 FileSystem.FileSystem
407> =>
408 Effect.gen(function* () {
409 const entryFileName = dirs.serverEntryFileName ?? "worker.js";
410 const entryName = `server/${entryFileName}`;
411 const modules = yield* FrameworkCore.readServerModulesFromDisk({
412 directory: dirs.serverDir,
413 prefix: "server",
414 }).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
415 const serverModules = FrameworkCore.sortServerModules(
416 modules.filter(
417 (module) =>
418 !NON_MODULE_SERVER_FILES.has(module.name.slice("server/".length)),
419 ),
420 entryName,
421 );
422 if (serverModules.length === 0) {
423 return yield* Effect.fail(
424 fail(
425 `The Octane build produced no server modules in ${dirs.serverDir}`,
426 ),
427 );
428 }
429 if (serverModules[0]?.name !== entryName) {
430 return yield* Effect.fail(
431 fail(
432 `The Octane build produced no "${entryName}" entry in ${dirs.serverDir} — ` +
433 "did the deploy adapter's adapt() pass run?",
434 ),
435 );
436 }
437 return {
438 distDirectory: dirs.dir,
439 clientDirectory: dirs.clientDir,
440 serverModules,
441 externalWorkspaces: new Set<string>(),
442 } satisfies FrameworkCore.BuildOutput;
443 });
444
445/**
446 * A `Layer` providing framework-core's `Framework` service for an Octane
447 * project — the fully-typed entrypoint for `e2e.config.ts` (harness form 4)
448 * and alchemy's `Website.Octane` composite.
449 */
450export const layer = (
451 options?: OctaneOptions,
452): Layer.Layer<Framework, never, FileSystem.FileSystem | Path.Path> =>
453 Layer.effect(Framework, make(options));