diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index 7a2e0f1ac7..e6d184354d 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -1,5 +1,22 @@ # Changelog +## 0.11.8 + +### 🎉 [New Feature] Serving the Client From a Subdirectory + +You can now serve the client from a subdirectory. This is useful if you want to serve the client from a subdirectory of your domain, e.g. `https://example.com/my-app/`. + +To do this, you need to add the `client.baseDir` property to your `.wasp` file: + +```wasp +app todoApp { + // ... + client: { + baseDir: "/my-app", + }, +} +``` + ## 0.11.7 ### 🐞 Bug fixes / 🔧 small improvements diff --git a/waspc/data/Generator/templates/react-app/src/router.tsx b/waspc/data/Generator/templates/react-app/src/router.tsx index 46be850b22..1c290df6d0 100644 --- a/waspc/data/Generator/templates/react-app/src/router.tsx +++ b/waspc/data/Generator/templates/react-app/src/router.tsx @@ -47,7 +47,7 @@ export const routes = { export type Routes = RouteDefinitionsToRoutes const router = ( - + {=# rootComponent.isDefined =} <{= rootComponent.importIdentifier =}> {=/ rootComponent.isDefined =} diff --git a/waspc/data/Generator/templates/react-app/vite.config.ts b/waspc/data/Generator/templates/react-app/vite.config.ts index ae660116f5..31e1055ddb 100644 --- a/waspc/data/Generator/templates/react-app/vite.config.ts +++ b/waspc/data/Generator/templates/react-app/vite.config.ts @@ -12,9 +12,10 @@ const _waspUserProvidedConfig = {}; {=/ customViteConfig.isDefined =} const defaultViteConfig = { + base: "{= baseDir =}", plugins: [react()], server: { - port: 3000, + port: {= defaultClientPort =}, host: "0.0.0.0", open: true, }, diff --git a/waspc/data/Generator/templates/server/src/config.js b/waspc/data/Generator/templates/server/src/config.js index 073492259e..e38a10e965 100644 --- a/waspc/data/Generator/templates/server/src/config.js +++ b/waspc/data/Generator/templates/server/src/config.js @@ -32,7 +32,7 @@ const resolvedConfig = merge(config.all, config[env]) export default resolvedConfig function getDevelopmentConfig() { - const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000'; + const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL || '{= defaultClientUrl =}'); return { frontendUrl, allowedCORSOrigins: '*', diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums index d76ab50967..83674f3df6 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums @@ -109,7 +109,7 @@ "file", "server/src/config.js" ], - "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27" + "d135535e045e5f5852e0b6d8bd49360e7231021cd38b540f419f5f44c6158dc2" ], [ [ @@ -459,7 +459,7 @@ "file", "web-app/src/router.tsx" ], - "79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197" + "067478c4990bbe966fa1984cd9db91aba9aaa68196c5858eab787eb376ab48b9" ], [ [ @@ -564,6 +564,6 @@ "file", "web-app/vite.config.ts" ], - "ba22ae0b9027a2a4d3cd2689e9a9e1caff526b96dfab5e7f0f58f194dff830d9" + "08962d79f2d71eb470ee85dee03db6deca7ede28df9d41542bbaea752db0eeed" ] ] \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js index 9230b7dc58..35ba136633 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js @@ -26,7 +26,7 @@ const resolvedConfig = merge(config.all, config[env]) export default resolvedConfig function getDevelopmentConfig() { - const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000'; + const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000/'); return { frontendUrl, allowedCORSOrigins: '*', diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx index 416355f025..a18069e2e3 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.tsx @@ -24,7 +24,7 @@ export const routes = { export type Routes = RouteDefinitionsToRoutes const router = ( - + {Object.entries(routes).map(([routeKey, route]) => ( const router = ( - + {Object.entries(routes).map(([routeKey, route]) => ( const router = ( - + {Object.entries(routes).map(([routeKey, route]) => ( diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/vite.config.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/vite.config.ts index c30ab7ea88..0bc670f493 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/vite.config.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/vite.config.ts @@ -6,6 +6,7 @@ import customViteConfig from './src/ext-src/vite.config' const _waspUserProvidedConfig = customViteConfig const defaultViteConfig = { + base: "/", plugins: [react()], server: { port: 3000, diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index e7fee5027f..661e200e8a 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -116,7 +116,7 @@ "file", "server/src/config.js" ], - "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27" + "d135535e045e5f5852e0b6d8bd49360e7231021cd38b540f419f5f44c6158dc2" ], [ [ @@ -515,7 +515,7 @@ "file", "web-app/src/router.tsx" ], - "79192ef1e636c2573815ca7151cf8e3a76ebb00a0b0ee3e804cbb60cba5f7197" + "067478c4990bbe966fa1984cd9db91aba9aaa68196c5858eab787eb376ab48b9" ], [ [ @@ -620,6 +620,6 @@ "file", "web-app/vite.config.ts" ], - "ba22ae0b9027a2a4d3cd2689e9a9e1caff526b96dfab5e7f0f58f194dff830d9" + "08962d79f2d71eb470ee85dee03db6deca7ede28df9d41542bbaea752db0eeed" ] ] \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js index 9230b7dc58..35ba136633 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js @@ -26,7 +26,7 @@ const resolvedConfig = merge(config.all, config[env]) export default resolvedConfig function getDevelopmentConfig() { - const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000'; + const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000/'); return { frontendUrl, allowedCORSOrigins: '*', diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.tsx b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.tsx index 416355f025..a18069e2e3 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.tsx +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.tsx @@ -24,7 +24,7 @@ export const routes = { export type Routes = RouteDefinitionsToRoutes const router = ( - + {Object.entries(routes).map(([routeKey, route]) => ( const router = ( - + {Object.entries(routes).map(([routeKey, route]) => ( Maybe ValidationError @@ -364,6 +366,19 @@ validatePrismaOptions spec = prismaClientPreviewFeatures = AS.Db.clientPreviewFeatures =<< prismaOptions prismaDbExtensions = AS.Db.dbExtensions =<< prismaOptions +validateWebAppBaseDir :: AppSpec -> [ValidationError] +validateWebAppBaseDir spec = case maybeBaseDir of + Just baseDir + | not (startsWithSlash baseDir) -> + [GenericValidationError "The app.client.baseDir should start with a slash e.g. \"/test\""] + _anyOtherCase -> [] + where + maybeBaseDir = Client.baseDir =<< AS.App.client (snd $ getApp spec) + + startsWithSlash :: String -> Bool + startsWithSlash ('/' : _) = True + startsWithSlash _ = False + -- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function). -- TODO: It would be great if we could ensure this at type level, but we decided that was too much work for now. -- Check https://github.com/wasp-lang/wasp/pull/455 for considerations on this and analysis of different approaches. diff --git a/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs b/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs index dbce32655b..7d55fd8d07 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs @@ -1,6 +1,5 @@ module Wasp.Generator.ServerGenerator.ConfigG ( genConfigFile, - configFileInSrcDir, ) where @@ -12,6 +11,7 @@ import Wasp.AppSpec.Valid (isAuthEnabled) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator.Common as C +import qualified Wasp.Generator.WebAppGenerator.Common as WebApp import Wasp.Project.Db (databaseUrlEnvVarName) genConfigFile :: AppSpec -> Generator FileDraft @@ -22,7 +22,8 @@ genConfigFile spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tm tmplData = object [ "isAuthEnabled" .= isAuthEnabled spec, - "databaseUrlEnvVarName" .= databaseUrlEnvVarName + "databaseUrlEnvVarName" .= databaseUrlEnvVarName, + "defaultClientUrl" .= WebApp.getDefaultClientUrl spec ] configFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' diff --git a/waspc/src/Wasp/Generator/WebAppGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator.hs index 4f5b64d79d..89cad1261f 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator.hs @@ -339,7 +339,11 @@ genViteConfig spec = return $ C.mkTmplFdWithData tmplFile tmplData where tmplFile = C.asTmplFile [relfile|vite.config.ts|] tmplData = - object ["customViteConfig" .= jsImportToImportJson (makeCustomViteConfigJsImport <$> AS.customViteConfigPath spec)] + object + [ "customViteConfig" .= jsImportToImportJson (makeCustomViteConfigJsImport <$> AS.customViteConfigPath spec), + "baseDir" .= SP.fromAbsDirP (C.getBaseDir spec), + "defaultClientPort" .= C.defaultClientPort + ] makeCustomViteConfigJsImport :: Path' (Rel SourceExternalCodeDir) File' -> JsImport makeCustomViteConfigJsImport pathToConfig = makeJsImport importPath importName diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs index 409cee65c5..06005ca2bb 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs @@ -20,14 +20,21 @@ module Wasp.Generator.WebAppGenerator.Common toViteImportPath, staticAssetsDirInWebAppDir, WebAppStaticAssetsDir, + getBaseDir, + getDefaultClientUrl, + defaultClientPort, ) where import qualified Data.Aeson as Aeson -import Data.Maybe (fromJust) -import StrongPath (Dir, File, File', Path, Path', Posix, Rel, reldir, ()) +import Data.Maybe (fromJust, fromMaybe) +import StrongPath (Abs, Dir, File, File', Path, Path', Posix, Rel, absdirP, reldir, ()) import qualified StrongPath as SP import System.FilePath (splitExtension) +import Wasp.AppSpec (AppSpec) +import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Client as AS.App.Client +import Wasp.AppSpec.Valid (getApp) import Wasp.Generator.Common ( GeneratedSrcDir, ProjectRootDir, @@ -123,3 +130,14 @@ toViteImportPath :: Path Posix (Rel r) (File f) -> Path Posix (Rel r) (File f) toViteImportPath = fromJust . SP.parseRelFileP . dropExtension . SP.fromRelFileP where dropExtension = fst . splitExtension + +getBaseDir :: AppSpec -> Path Posix Abs (Dir ()) +getBaseDir spec = fromMaybe [absdirP|/|] maybeBaseDir + where + maybeBaseDir = SP.parseAbsDirP =<< (AS.App.Client.baseDir =<< AS.App.client (snd $ getApp spec)) + +defaultClientPort :: Int +defaultClientPort = 3000 + +getDefaultClientUrl :: AppSpec -> String +getDefaultClientUrl spec = "http://localhost:" ++ show defaultClientPort ++ SP.fromAbsDirP (getBaseDir spec) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs index e8870ac332..b7998d03f5 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs @@ -10,6 +10,7 @@ import qualified Data.Aeson as Aeson import Data.List (find) import Data.Maybe (fromMaybe) import StrongPath (Dir, Path, Rel, reldir, reldirP, relfile, ()) +import qualified StrongPath as SP import StrongPath.Types (Posix) import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS @@ -36,7 +37,8 @@ data RouterTemplateData = RouterTemplateData _isAuthEnabled :: Bool, _isExternalAuthEnabled :: Bool, _externalAuthProviders :: ![ExternalAuthProviderTemplateData], - _rootComponent :: Aeson.Value + _rootComponent :: Aeson.Value, + _baseDir :: String } instance ToJSON RouterTemplateData where @@ -47,7 +49,8 @@ instance ToJSON RouterTemplateData where "isAuthEnabled" .= _isAuthEnabled routerTD, "isExternalAuthEnabled" .= _isExternalAuthEnabled routerTD, "externalAuthProviders" .= _externalAuthProviders routerTD, - "rootComponent" .= _rootComponent routerTD + "rootComponent" .= _rootComponent routerTD, + "baseDir" .= _baseDir routerTD ] data RouteTemplateData = RouteTemplateData @@ -124,7 +127,8 @@ createRouterTemplateData spec = _isAuthEnabled = isAuthEnabled spec, _isExternalAuthEnabled = (AS.App.Auth.isExternalAuthEnabled <$> maybeAuth) == Just True, _externalAuthProviders = externalAuthProviders, - _rootComponent = extImportToImportJson relPathToWebAppSrcDir maybeRootComponent + _rootComponent = extImportToImportJson relPathToWebAppSrcDir maybeRootComponent, + _baseDir = SP.fromAbsDirP $ C.getBaseDir spec } where routes = map (createRouteTemplateData spec) $ AS.getRoutes spec diff --git a/waspc/test/AnalyzerTest.hs b/waspc/test/AnalyzerTest.hs index 31560fbac1..a82f4ed41a 100644 --- a/waspc/test/AnalyzerTest.hs +++ b/waspc/test/AnalyzerTest.hs @@ -62,7 +62,8 @@ spec_Analyzer = do " },", " client: {", " rootComponent: import { App } from \"@client/App.jsx\",", - " setupFn: import { setupClient } from \"@client/baz.js\"", + " setupFn: import { setupClient } from \"@client/baz.js\",", + " baseDir: \"/\"", " },", " db: {", " system: PostgreSQL,", @@ -176,7 +177,8 @@ spec_Analyzer = do ExtImport (ExtImportField "setupClient") (fromJust $ SP.parseRelFileP "baz.js"), Client.rootComponent = Just $ - ExtImport (ExtImportField "App") (fromJust $ SP.parseRelFileP "App.jsx") + ExtImport (ExtImportField "App") (fromJust $ SP.parseRelFileP "App.jsx"), + Client.baseDir = Just "/" }, App.db = Just diff --git a/web/docs/project/_baseDirEnvNote.md b/web/docs/project/_baseDirEnvNote.md new file mode 100644 index 0000000000..472263cc6a --- /dev/null +++ b/web/docs/project/_baseDirEnvNote.md @@ -0,0 +1,6 @@ +:::caution Setting the correct env variable + +If you set the `baseDir` option, make sure that the `WASP_WEB_CLIENT_URL` env variable also includes that base directory. + +For example, if you are serving your app from `https://example.com/my-app`, the `WASP_WEB_CLIENT_URL` should be also set to `https://example.com/my-app`, and not just `https://example.com`. +::: \ No newline at end of file diff --git a/web/docs/project/client-config.md b/web/docs/project/client-config.md index bf879f562c..e7ca198e05 100644 --- a/web/docs/project/client-config.md +++ b/web/docs/project/client-config.md @@ -2,6 +2,8 @@ title: Client Config --- +import BaseDirEnvNote from './_baseDirEnvNote.md' + import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' You can configure the client using the `client` field inside the `app` declaration: @@ -265,6 +267,26 @@ explained in Read more about the setup function in the [API Reference](#setupfn-clientimport). +## Base Directory + +If you need to serve the client from a subdirectory, you can use the `baseDir` option: + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + baseDir: "/my-app", + } +} +``` + +This means that if you serve your app from `https://example.com/my-app`, the +router will work correctly, and all the assets will be served from +`https://example.com/my-app`. + + + ## API Reference @@ -290,7 +312,8 @@ app MyApp { // ... client: { rootComponent: import Root from "@client/Root.tsx", - setupFn: import mySetupFunction from "@client/myClientSetupCode.ts" + setupFn: import mySetupFunction from "@client/myClientSetupCode.ts", + baseDir: "/my-app", } } ``` @@ -413,3 +436,14 @@ Client has the following options: + +- #### `baseDir: String` + + If you need to serve the client from a subdirectory, you can use the `baseDir` option. + + If you set `baseDir` to `/my-app` for example, that will make Wasp set the `basename` prop of the `Router` to + `/my-app`. It will also set the `base` option of the Vite config to `/my-app`. + + This means that if you serve your app from `https://example.com/my-app`, the router will work correctly, and all the assets will be served from `https://example.com/my-app`. + +