1import {
2 Framework,
3 FrameworkError,
4 readBuildOutput,
5 writeBuildOutput,
6 type BuildOutput,
7 type DeployTargetServer,
8} from "@alchemy.run/frontend-frameworks/core";
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import type * as Scope from "effect/Scope";
15import { makeCloudflareTarget } from "./CloudflareTarget.ts";
16import { Cwd } from "./Cwd.ts";
17import * as Options from "./Options.ts";
18
19export interface Instance {
20 url: URL;
21 fetch(path: string, init?: RequestInit): Promise<Response>;
22 fetchText(path: string, init?: RequestInit): Promise<string>;
23 fetchJson<T>(path: string, init?: RequestInit): Promise<T>;
24 dispose(): Promise<void>;
25}
26
27declare namespace Instance {
28 type Raw = Omit<Instance, "dispose">;
29}
30
31export class Server extends Context.Service<
32 Server,
33 {
34 readonly live: () => Effect.Effect<
35 Instance.Raw,
36 FrameworkError,
37 Scope.Scope
38 >;
39 readonly dev: () => Effect.Effect<
40 Instance.Raw,
41 FrameworkError,
42 Scope.Scope
43 >;
44 }
45>()("@alchemy.run/cloudflare-test-tools/e2e/Server") {}
46
47/**
48 * Where the harness persists a fixture's `BuildOutput`, relative to the
49 * fixture root. Persistence is the harness's E2E mechanism (preview serves
50 * from it) — it is NOT part of the `Framework` contract; `Framework.build`
51 * returns the `BuildOutput` purely in-memory.
52 */
53export const BUILD_OUTPUT_FILE = "dist/build.json";
54
55/**
56 * Run `Framework.build` and persist the returned `BuildOutput` to
57 * {@link BUILD_OUTPUT_FILE} (framework-core's `writeBuildOutput`), so
58 * `preview` can serve it without rebuilding.
59 *
60 * The fixture's `Options.root` (if any) is resolved against the harness cwd
61 * and threaded into `Framework.build` as `FrameworkBuildOptions.root`; the
62 * persisted `dist/build.json` stays anchored at the fixture root regardless.
63 */
64export const buildAndPersist: Effect.Effect<
65 BuildOutput,
66 FrameworkError,
67 Framework | FileSystem.FileSystem | Path.Path
68> = Effect.gen(function* () {
69 const framework = yield* Framework;
70 const path = yield* Path.Path;
71 const cwd = yield* Cwd;
72 const options = yield* Options.load().pipe(
73 Effect.mapError(
74 (cause) =>
75 new FrameworkError({ message: "Failed to load e2e.config.ts", cause }),
76 ),
77 );
78 const root = yield* Options.resolveRoot(options);
79 const output = yield* framework.build(
80 root !== undefined ? { root } : undefined,
81 );
82 yield* writeBuildOutput(path.resolve(cwd, BUILD_OUTPUT_FILE), output).pipe(
83 Effect.mapError(
84 (cause) =>
85 new FrameworkError({ message: "Failed to write build.json", cause }),
86 ),
87 );
88 return output;
89});
90
91/** Wrap a target's serve result (or any base URL) into fetch helpers. */
92const toInstance = (server: DeployTargetServer): Instance.Raw => {
93 const url = new URL(server.url);
94 const baseFetch =
95 server.fetch ??
96 ((path: string, init?: RequestInit) => fetch(new URL(path, url), init));
97 return {
98 url,
99 fetch: baseFetch,
100 fetchText: (path: string, init?: RequestInit) =>
101 baseFetch(path, init).then((response) => response.text()),
102 fetchJson: <T>(path: string, init?: RequestInit) =>
103 baseFetch(path, init).then((response) => response.json() as Promise<T>),
104 };
105};
106
107export const layer = Layer.effect(
108 Server,
109 Effect.gen(function* () {
110 const framework = yield* Framework;
111 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;
113 const cwd = yield* Cwd;
114 const options = yield* Options.load();
115 // The deploy target for this fixture. Only the cloudflare target exists
116 // today; `Options.TargetOptions` is where another platform would be
117 // selected.
118 const target = makeCloudflareTarget(
119 Options.resolveCloudflareOptions(options),
120 );
121 // The fixture's project root (Options.root resolved against the harness
122 // cwd) — threaded into Framework.dev; buildAndPersist resolves it itself.
123 const root = yield* Options.resolveRoot(options);
124
125 const buildJsonPath = path.resolve(cwd, BUILD_OUTPUT_FILE);
126 const persistedBuild = buildAndPersist.pipe(
127 Effect.provideService(Framework, framework),
128 Effect.provideService(FileSystem.FileSystem, fs),
129 Effect.provideService(Path.Path, path),
130 );
131
132 const live = () =>
133 readBuildOutput(buildJsonPath).pipe(
134 Effect.provideService(FileSystem.FileSystem, fs),
135 Effect.catch(() => persistedBuild),
136 Effect.flatMap((build) =>
137 target.serve({ root: cwd, output: build }).pipe(
138 Effect.map(toInstance),
139 Effect.mapError(
140 (error) =>
141 new FrameworkError({
142 message: error.message,
143 cause: error.cause,
144 }),
145 ),
146 Effect.provideService(FileSystem.FileSystem, fs),
147 Effect.provideService(Path.Path, path),
148 ),
149 ),
150 );
151
152 const dev = () =>
153 framework
154 .dev(root !== undefined ? { root } : undefined)
155 .pipe(Effect.map(toInstance));
156
157 return Server.of({
158 live,
159 dev,
160 });
161 }),
162);