1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Namespace from "@/Namespace.ts";
3import * as Stack from "@/Stack.ts";
4import { Stage } from "@/Stage.ts";
5import { inMemoryState, type State } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import { expect } from "alchemy-test";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11
12const { test } = Test.make({
13 providers: Layer.empty,
14 state: inMemoryState(),
15});
16
17// A resource declared once, at module scope, and referenced from a site's
18// `env` — the pattern `examples/cloudflare-website-tanstack-start` uses.
19const Cache = Cloudflare.KV.Namespace("Cache", {});
20
21/** Compile the stack and return its registered resources. */
22const compile = <A, Err = never, Req = never>(
23 effect: Effect.Effect<A, Err, Req>,
24): Effect.Effect<Stack.CompiledStack["resources"], Err, State> =>
25 effect.pipe(
26 // @ts-expect-error - Stack.make's typing erases R unsoundly here
27 Stack.make({
28 name: "test",
29 providers: Layer.empty,
30 state: inMemoryState(),
31 }),
32 Effect.provideService(Stage, "test"),
33 Effect.map((stack: Stack.CompiledStack) => stack.resources),
34 );
35
36/** Compile the stack and return the FQN of every registered resource. */
37const fqns = <A, Err = never, Req = never>(
38 effect: Effect.Effect<A, Err, Req>,
39): Effect.Effect<string[], Err, State> =>
40 compile(effect).pipe(
41 Effect.map((resources) => Object.keys(resources).sort()),
42 );
43
44test(
45 "StaticSite declares env resources in the caller's namespace",
46 Effect.gen(function* () {
47 const keys = yield* fqns(
48 Effect.gen(function* () {
49 yield* Cache;
50 yield* Cloudflare.Website.StaticSite("Site", {
51 command: "echo build",
52 outdir: "dist",
53 main: "./worker.ts",
54 env: { CACHE: Cache },
55 });
56 }),
57 );
58 // Only the build sub-resource is namespaced; the Worker is the site
59 // itself and `Cache` stays where the caller declared it.
60 expect(keys).toEqual(["Cache", "Site", "Site/Build"]);
61 }),
62 {
63 tags: [
64 "unit",
65 "provider:cloudflare",
66 "provider:cloudflare:kv",
67 "provider:cloudflare:website",
68 "local",
69 ],
70 },
71);
72
73test(
74 "StaticSite claims its pre-#1053 `<id>/Worker` FQN, including when nested",
75 Effect.gen(function* () {
76 const resources = yield* compile(
77 Effect.gen(function* () {
78 // Top-level site.
79 yield* Cloudflare.Website.StaticSite("Site", {
80 command: "echo build",
81 outdir: "dist",
82 main: "./worker.ts",
83 });
84 // Site nested inside a caller namespace — the former FQN must
85 // resolve under the same `App/` prefix as the site itself.
86 yield* Cloudflare.Website.StaticSite("Nested", {
87 command: "echo build",
88 outdir: "dist",
89 main: "./worker.ts",
90 }).pipe(Namespace.push("App"));
91 }),
92 );
93 expect(resources["Site"]?.FormerFqns).toEqual(["Site/Worker"]);
94 expect(resources["App/Nested"]?.FormerFqns).toEqual(["App/Nested/Worker"]);
95 }),
96 {
97 tags: [
98 "unit",
99 "provider:cloudflare",
100 "provider:cloudflare:website",
101 "local",
102 ],
103 },
104);
105
106test(
107 "Vite declares env resources in the caller's namespace",
108 Effect.gen(function* () {
109 const keys = yield* fqns(
110 Effect.gen(function* () {
111 yield* Cache;
112 yield* Cloudflare.Website.Vite("Site", {
113 main: "./worker.ts",
114 env: { CACHE: Cache },
115 });
116 }),
117 );
118 expect(keys).toEqual(["Cache", "Site"]);
119 }),
120 {
121 tags: [
122 "unit",
123 "provider:cloudflare",
124 "provider:cloudflare:kv",
125 "provider:cloudflare:website",
126 "local",
127 ],
128 },
129);
130
131test(
132 "Astro forwards the prerender environment to its source provider",
133 Effect.gen(function* () {
134 const resources = yield* compile(
135 Cloudflare.Website.Astro("Astro", {
136 prerenderEnvironment: "node",
137 sessionKVBindingName: false,
138 }),
139 );
140 expect(resources["Astro"]?.Props.source).toMatchObject({
141 provider: "@alchemy.run/frontend-frameworks/astro/source",
142 devMode: "server",
143 options: {
144 prerenderEnvironment: "node",
145 },
146 });
147 }),
148 {
149 tags: [
150 "unit",
151 "provider:cloudflare",
152 "provider:cloudflare:website",
153 "local",
154 ],
155 },
156);
157
158class WebsiteRoot extends Context.Service<WebsiteRoot, string>()(
159 "WebsiteRoot",
160) {}
161
162const astroProps = Effect.gen(function* () {
163 const rootDir = yield* WebsiteRoot;
164 return {
165 rootDir,
166 astro: { output: "static" as const },
167 assets: { notFoundHandling: "404-page" as const },
168 };
169});
170
171class AstroFromEffect extends Cloudflare.Website.Astro<AstroFromEffect>()(
172 "AstroClass",
173 astroProps,
174) {}
175
176const AstroFromEffectFunction = Cloudflare.Website.Astro(
177 "AstroFunction",
178 astroProps,
179);
180
181// Both overloads must retain the props Effect's requirements without adding an error.
182const functionRequiresRoot: WebsiteRoot extends Effect.Services<
183 typeof AstroFromEffectFunction
184>
185 ? true
186 : false = true;
187const classRequiresRoot: WebsiteRoot extends Effect.Services<
188 typeof AstroFromEffect
189>
190 ? true
191 : false = true;
192const functionIsInfallible: [
193 Effect.Error<typeof AstroFromEffectFunction>,
194] extends [never]
195 ? true
196 : false = true;
197const classIsInfallible: [Effect.Error<typeof AstroFromEffect>] extends [never]
198 ? true
199 : false = true;
200
201test(
202 "Astro function and class constructors evaluate typed props Effects before declaring resources",
203 Effect.gen(function* () {
204 expect(
205 functionRequiresRoot &&
206 classRequiresRoot &&
207 functionIsInfallible &&
208 classIsInfallible,
209 ).toBe(true);
210 const resources = yield* compile(
211 Effect.all([AstroFromEffectFunction, AstroFromEffect]).pipe(
212 Effect.provideService(WebsiteRoot, "./typed-astro"),
213 ),
214 );
215 // Static output must suppress auto-provisioning the session namespace.
216 expect(Object.keys(resources).sort()).toEqual([
217 "AstroClass",
218 "AstroFunction",
219 ]);
220 for (const id of ["AstroClass", "AstroFunction"]) {
221 expect(resources[id]?.Props.source).toMatchObject({
222 rootDir: "./typed-astro",
223 options: { astro: { output: "static" } },
224 });
225 expect(resources[id]?.Props.assets).toEqual({
226 notFoundHandling: "404-page",
227 });
228 }
229 }),
230 {
231 tags: [
232 "unit",
233 "provider:cloudflare",
234 "provider:cloudflare:website",
235 "local",
236 ],
237 },
238);