diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2a415179dd..7d3efa1ac5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,11 +41,14 @@ All that's required is to create an app. And make a tutorial or a blog post to h
Or you can re-build your existing pet project with Wasp. That would be cool!
-## Documentation
+## Documentation & Blog
It may sound like the simplest one, but it's super valuable! If you've found an issue, a broken link or if something was unclear on our [website](https://wasp-lang.dev/) - please, feel free to fix it :)
+Please make sure to **base your feature branches and PRs on the `release` branch** instead of `main`, since that's the one that is deployed to the website.
+
[**Documentation issues for beginners can be found here.**](https://github.com/wasp-lang/wasp/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22+label%3Adocumentation)
+If you'd like to write a blog post about Wasp, please contact us via [Discord](https://discord.gg/zKFDFrsHa9) to discuss the topic and the details.
Happy hacking!
\ No newline at end of file
diff --git a/examples/streaming/src/client/vite-env.d.ts b/examples/streaming/src/client/vite-env.d.ts
index 1623b9c79c..11f02fe2a0 100644
--- a/examples/streaming/src/client/vite-env.d.ts
+++ b/examples/streaming/src/client/vite-env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/todo-typescript/main.wasp b/examples/todo-typescript/main.wasp
index cd84cad132..9d4d180e24 100644
--- a/examples/todo-typescript/main.wasp
+++ b/examples/todo-typescript/main.wasp
@@ -1,6 +1,6 @@
app TodoTypescript {
wasp: {
- version: "^0.11.0"
+ version: "^0.12.0"
},
title: "ToDo TypeScript",
diff --git a/examples/waspello/README.md b/examples/waspello/README.md
index 395cd3903e..5fb8c11ea6 100644
--- a/examples/waspello/README.md
+++ b/examples/waspello/README.md
@@ -10,9 +10,9 @@ The backend is hosted on Fly.io at https://waspello.fly.dev.
# Development
### Database
-Wasp needs the Postgres database running. Check out the docs for details on [how to setup PostgreSQL](https://wasp-lang.dev/docs/language/features#postgresql)
+Wasp needs the Postgres database running.
-You can use `wasp start db` to start a PostgreSQL locally using Docker.
+Easiest way to do this is to use `wasp start db` to start a PostgreSQL locally using Docker.
### Env variables
Copy `env.server` to `.env.server` and fill in the values.
diff --git a/examples/waspleau/README.md b/examples/waspleau/README.md
index cd7cd2b0a6..8e9779267f 100644
--- a/examples/waspleau/README.md
+++ b/examples/waspleau/README.md
@@ -1,7 +1,7 @@
# Waspleau
Welcome to the Waspleau example! This is a small Wasp project that tracks status of wasp-lang/wasp repo via a nice looking dashboard.
-It pulls in data via [Jobs](https://wasp-lang.dev/docs/language/features#jobs) and stores them in the database.
+It pulls in data via [Jobs](https://wasp-lang.dev/docs/advanced/jobs) and stores them in the database.
This example project can serve as a good starting point for building your own dashboard with Wasp, that regularly pulls in external data by using Jobs Wasp feature.
diff --git a/waspc/.hlint.yaml b/waspc/.hlint.yaml
index 1b21470602..66a623d845 100644
--- a/waspc/.hlint.yaml
+++ b/waspc/.hlint.yaml
@@ -12,3 +12,4 @@
- ignore: {name: Use $>} # I find it makes code harder to read if enforced.
- ignore: {name: Use list comprehension} # We can decide this on our own.
- ignore: {name: Use ++} # I sometimes prefer concat over ++ due to the nicer formatting / extensibility.
+- ignore: {name: Redundant lambda} # Sometimes it is nicer to create explicit lambda then function.
diff --git a/waspc/README.md b/waspc/README.md
index d4fd2cf423..b4baf4cde0 100644
--- a/waspc/README.md
+++ b/waspc/README.md
@@ -362,11 +362,17 @@ NOTE: If building of your commit is suddenly taking much longer time, it might b
If it happens just once every so it is probably nothing to worry about. If it happens consistently, we should look into it.
### Typical Release Process
-- Ensure that all starter templates in `starter` repo are working with the version of Wasp we are about to release and upgrade their version of Wasp to the new one.
+- Starter templates
+ - Context: they are used by used by `wasp new`, you can find reference to them in `Wasp.Cli. ... .StarterTemplates`.
+ - In `StarterTemplates.hs` file, update git tag to new version of Wasp we are about to release (e.g. `wasp-v0.13.1-template`).
+ - Ensure that all starter templates are working with this new version of Wasp.
+ Update Wasp version in their main.wasp files. Finally, in their repos (for those templates that are on Github),
+ create new git tag that is the same as the new one in `StarterTemplates.hs` (e.g. `wasp-v0.13.1-template`).
- ChangeLog.md and version in waspc.cabal should already be up to date, but double check that they are correct and update them if needed. Also consider enriching and polishing ChangeLog.md a bit even if all the data is already there. Also check that ChangeLog has correction version of wasp specified.
- If you modified ChangeLog.md or waspc.cabal, create a PR, wait for approval and all the checks (CI) to pass, then squash and merge mentioned PR into `main`.
- Update your local repository state to have all remote changes (`git fetch`).
- Update `main` to contain changes from `release` by running `git merge release` while on the `main` branch. Resolve any conflicts.
+- Take a versioned "snapshot" of the current docs by running `npm run docusaurus docs:version {version}` in the [web](/web) dir. Check the README in the `web` dir for more details. Commit this change to `main`.
- Fast-forward `release` to this new, updated `main` by running `git merge main` while on the `release` branch.
- Make sure you are on `release` and then run `./new-release 0.x.y.z`.
- This will do some checks, tag it with new release version, and push it.
diff --git a/waspc/cli/exe/Main.hs b/waspc/cli/exe/Main.hs
index 5acb94ed85..24eddb03a4 100644
--- a/waspc/cli/exe/Main.hs
+++ b/waspc/cli/exe/Main.hs
@@ -19,7 +19,6 @@ import Wasp.Cli.Command.CreateNewProject (createNewProject)
import qualified Wasp.Cli.Command.CreateNewProject.AI as Command.CreateNewProject.AI
import Wasp.Cli.Command.Db (runDbCommand)
import qualified Wasp.Cli.Command.Db.Migrate as Command.Db.Migrate
-import qualified Wasp.Cli.Command.Db.Reset as Command.Db.Reset
import qualified Wasp.Cli.Command.Db.Seed as Command.Db.Seed
import qualified Wasp.Cli.Command.Db.Studio as Command.Db.Studio
import Wasp.Cli.Command.Deploy (deploy)
@@ -158,7 +157,7 @@ printUsage =
cmd " start Runs Wasp app in development mode, watching for file changes.",
cmd " start db Starts managed development database for you.",
cmd " db [args] Executes a database command. Run 'wasp db' for more info.",
- cmd " clean Deletes all generated code and other cached artifacts.",
+ cmd " clean Deletes all generated code, all cached artifacts, and the node_modules dir.",
" Wasp equivalent of 'have you tried closing and opening it again?'.",
cmd " build Generates full web app code, ready for deployment. Use when deploying or ejecting.",
cmd " deploy Deploys your Wasp app to cloud hosting providers.",
@@ -200,7 +199,6 @@ dbCli :: [String] -> IO ()
dbCli args = case args of
["start"] -> runCommand Command.Start.Db.start
"migrate-dev" : optionalMigrateArgs -> runDbCommand $ Command.Db.Migrate.migrateDev optionalMigrateArgs
- ["reset"] -> runDbCommand Command.Db.Reset.reset
["seed"] -> runDbCommand $ Command.Db.Seed.seed Nothing
["seed", seedName] -> runDbCommand $ Command.Db.Seed.seed $ Just seedName
["studio"] -> runDbCommand Command.Db.Studio.studio
diff --git a/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs b/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs
index 1e90148e57..0a4e24f085 100644
--- a/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs
@@ -26,7 +26,26 @@ bashCompletion = do
["db", cmdPrefix] -> listMatchingCommands cmdPrefix dbSubCommands
_ -> liftIO . putStrLn $ ""
where
- commands = ["new", "version", "waspls", "start", "db", "clean", "uninstall", "build", "telemetry", "deps", "info", "completion", "completion:generate"]
+ commands =
+ [ "new",
+ "new:ai",
+ "version",
+ "waspls",
+ "completion",
+ "completion:generate",
+ "uninstall",
+ "start",
+ "db",
+ "clean",
+ "build",
+ "deploy",
+ "telemetry",
+ "deps",
+ "dockerfile",
+ "info",
+ "test",
+ "studio"
+ ]
dbSubCommands = ["migrate-dev", "studio"]
listMatchingCommands :: String -> [String] -> Command ()
listMatchingCommands cmdPrefix cmdList = listCommands $ filter (cmdPrefix `isPrefixOf`) cmdList
diff --git a/waspc/cli/src/Wasp/Cli/Command/Build.hs b/waspc/cli/src/Wasp/Cli/Command/Build.hs
index 1f3b6e8d28..591fc1256e 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Build.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Build.hs
@@ -16,13 +16,13 @@ import Wasp.Cli.Command (Command, CommandError (..))
import Wasp.Cli.Command.Compile (compileIOWithOptions, printCompilationResult)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Common
import Wasp.Cli.Message (cliSendMessage)
import Wasp.CompileOptions (CompileOptions (..))
import qualified Wasp.Generator
import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning))
import qualified Wasp.Message as Msg
-import Wasp.Project (CompileError, CompileWarning)
+import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
+import Wasp.Project.Common (buildDirInDotWaspDir, dotWaspDirInWaspProjectDir)
-- | Builds Wasp project that the current working directory is part of.
-- Does all the steps, from analysis to generation, and at the end writes generated code
@@ -35,8 +35,8 @@ build :: Command ()
build = do
InWaspProject waspProjectDir <- require
let buildDir =
- waspProjectDir > Common.dotWaspDirInWaspProjectDir
- > Common.buildDirInDotWaspDir
+ waspProjectDir > dotWaspDirInWaspProjectDir
+ > buildDirInDotWaspDir
buildDirFilePath = SP.fromAbsDir buildDir
doesBuildDirExist <- liftIO $ doesDirectoryExist buildDirFilePath
@@ -58,16 +58,14 @@ build = do
CommandError "Building of wasp project failed" $ show (length errors) ++ " errors found"
buildIO ::
- Path' Abs (Dir Common.WaspProjectDir) ->
+ Path' Abs (Dir WaspProjectDir) ->
Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
IO ([CompileWarning], [CompileError])
buildIO waspProjectDir buildDir = compileIOWithOptions options waspProjectDir buildDir
where
options =
CompileOptions
- { externalClientCodeDirPath = waspProjectDir > Common.extClientCodeDirInWaspProjectDir,
- externalServerCodeDirPath = waspProjectDir > Common.extServerCodeDirInWaspProjectDir,
- externalSharedCodeDirPath = waspProjectDir > Common.extSharedCodeDirInWaspProjectDir,
+ { waspProjectDirPath = waspProjectDir,
isBuild = True,
sendMessage = cliSendMessage,
-- Ignore "DB needs migration warnings" during build, as that is not a required step.
diff --git a/waspc/cli/src/Wasp/Cli/Command/Clean.hs b/waspc/cli/src/Wasp/Cli/Command/Clean.hs
index 87a3b1346e..e5dccab574 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Clean.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Clean.hs
@@ -3,26 +3,18 @@ module Wasp.Cli.Command.Clean
)
where
-import Control.Monad.IO.Class (liftIO)
import qualified StrongPath as SP
-import System.Directory
- ( doesDirectoryExist,
- removeDirectoryRecursive,
- )
import Wasp.Cli.Command (Command)
-import Wasp.Cli.Command.Message (cliSendMessageC)
+import Wasp.Cli.Command.Common (deleteDirectoryIfExistsVerbosely)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Common
-import qualified Wasp.Message as Msg
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, nodeModulesDirInWaspProjectDir)
clean :: Command ()
clean = do
InWaspProject waspProjectDir <- require
- let dotWaspDirFp = SP.toFilePath $ waspProjectDir SP.> Common.dotWaspDirInWaspProjectDir
- cliSendMessageC $ Msg.Start "Deleting .wasp/ directory..."
- doesDotWaspDirExist <- liftIO $ doesDirectoryExist dotWaspDirFp
- if doesDotWaspDirExist
- then do
- liftIO $ removeDirectoryRecursive dotWaspDirFp
- cliSendMessageC $ Msg.Success "Deleted .wasp/ directory."
- else cliSendMessageC $ Msg.Success "Nothing to delete: .wasp directory does not exist."
+
+ let dotWaspDir = waspProjectDir SP.> dotWaspDirInWaspProjectDir
+ let nodeModulesDir = waspProjectDir SP.> nodeModulesDirInWaspProjectDir
+
+ deleteDirectoryIfExistsVerbosely dotWaspDir
+ deleteDirectoryIfExistsVerbosely nodeModulesDir
diff --git a/waspc/cli/src/Wasp/Cli/Command/Common.hs b/waspc/cli/src/Wasp/Cli/Command/Common.hs
index 8f40ab7249..df5c396114 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Common.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Common.hs
@@ -1,17 +1,23 @@
module Wasp.Cli.Command.Common
( readWaspCompileInfo,
throwIfExeIsNotAvailable,
+ deleteDirectoryIfExistsVerbosely,
)
where
import Control.Monad.Except
import qualified Control.Monad.Except as E
import StrongPath (Abs, Dir, Path')
+import qualified StrongPath as SP
import StrongPath.Operations
-import System.Directory (findExecutable)
+import System.Directory
+ ( findExecutable,
+ )
import Wasp.Cli.Command (Command, CommandError (..))
-import qualified Wasp.Cli.Common as Cli.Common
+import Wasp.Cli.Command.Message (cliSendMessageC)
+import qualified Wasp.Message as Msg
import Wasp.Project (WaspProjectDir)
+import qualified Wasp.Project.Common as Project.Common
import Wasp.Util (ifM)
import qualified Wasp.Util.IO as IOUtil
@@ -23,9 +29,9 @@ readWaspCompileInfo waspDir =
(return "No compile information found")
where
dotWaspInfoFile =
- waspDir > Cli.Common.dotWaspDirInWaspProjectDir
- > Cli.Common.generatedCodeDirInDotWaspDir
- > Cli.Common.dotWaspInfoFileInGeneratedCodeDir
+ waspDir > Project.Common.dotWaspDirInWaspProjectDir
+ > Project.Common.generatedCodeDirInDotWaspDir
+ > Project.Common.dotWaspInfoFileInGeneratedCodeDir
throwIfExeIsNotAvailable :: String -> String -> Command ()
throwIfExeIsNotAvailable exeName explanationMsg = do
@@ -34,3 +40,16 @@ throwIfExeIsNotAvailable exeName explanationMsg = do
Nothing ->
E.throwError $
CommandError ("Couldn't find `" <> exeName <> "` executable") explanationMsg
+
+deleteDirectoryIfExistsVerbosely :: Path' Abs (Dir d) -> Command ()
+deleteDirectoryIfExistsVerbosely dir = do
+ cliSendMessageC $ Msg.Start $ "Deleting the " ++ dirName ++ " directory..."
+ dirExist <- liftIO $ IOUtil.doesDirectoryExist dir
+ if dirExist
+ then do
+ liftIO $ IOUtil.removeDirectory dir
+ cliSendMessageC $ Msg.Success $ "Deleted the " ++ dirName ++ " directory."
+ else do
+ cliSendMessageC $ Msg.Success $ "Nothing to delete: The " ++ dirName ++ " directory does not exist."
+ where
+ dirName = SP.toFilePath $ basename dir
diff --git a/waspc/cli/src/Wasp/Cli/Command/Compile.hs b/waspc/cli/src/Wasp/Cli/Command/Compile.hs
index f5a208aecf..c1bb97d15f 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Compile.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Compile.hs
@@ -20,13 +20,13 @@ import qualified Wasp.AppSpec as AS
import Wasp.Cli.Command (Command, CommandError (..))
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Common
import Wasp.Cli.Message (cliSendMessage)
import Wasp.CompileOptions (CompileOptions (..))
import qualified Wasp.Generator
import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
import qualified Wasp.Project
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
-- | Same like 'compileWithOptions', but with default compile options.
compile :: Command [CompileWarning]
@@ -47,8 +47,8 @@ compileWithOptions :: CompileOptions -> Command [CompileWarning]
compileWithOptions options = do
InWaspProject waspProjectDir <- require
let outDir =
- waspProjectDir > Common.dotWaspDirInWaspProjectDir
- > Common.generatedCodeDirInDotWaspDir
+ waspProjectDir > dotWaspDirInWaspProjectDir
+ > generatedCodeDirInDotWaspDir
cliSendMessageC $ Msg.Start "Compiling wasp project..."
(warnings, errors) <- liftIO $ compileIOWithOptions options waspProjectDir outDir
@@ -115,9 +115,7 @@ compileIOWithOptions options waspProjectDir outDir =
defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions
defaultCompileOptions waspProjectDir =
CompileOptions
- { externalServerCodeDirPath = waspProjectDir > Common.extServerCodeDirInWaspProjectDir,
- externalClientCodeDirPath = waspProjectDir > Common.extClientCodeDirInWaspProjectDir,
- externalSharedCodeDirPath = waspProjectDir > Common.extSharedCodeDirInWaspProjectDir,
+ { waspProjectDirPath = waspProjectDir,
isBuild = False,
sendMessage = cliSendMessage,
generatorWarningsFilter = id
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs
index 920eb222c5..6dd78fd804 100644
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs
@@ -5,11 +5,12 @@ where
import Control.Monad.IO.Class (liftIO)
import Data.Function ((&))
+import qualified StrongPath as SP
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Call (Arguments)
import qualified Wasp.Cli.Command.CreateNewProject.AI as AI
import Wasp.Cli.Command.CreateNewProject.ArgumentsParser (parseNewProjectArgs)
-import Wasp.Cli.Command.CreateNewProject.Common (printGettingStartedInstructions, throwProjectCreationError)
+import qualified Wasp.Cli.Command.CreateNewProject.Common as Common
import Wasp.Cli.Command.CreateNewProject.ProjectDescription
( NewProjectDescription (..),
obtainNewProjectDescription,
@@ -18,22 +19,24 @@ import Wasp.Cli.Command.CreateNewProject.StarterTemplates
( DirBasedTemplateMetadata (_path),
StarterTemplate (..),
getStarterTemplates,
+ getTemplateStartingInstructions,
)
+import Wasp.Cli.Command.CreateNewProject.StarterTemplates.GhRepo (createProjectOnDiskFromGhRepoTemplate)
import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local (createProjectOnDiskFromLocalTemplate)
-import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote (createProjectOnDiskFromRemoteTemplate)
import Wasp.Cli.Command.Message (cliSendMessageC)
import qualified Wasp.Message as Msg
+import qualified Wasp.Util.Terminal as Term
-- | It receives all of the arguments that were passed to the `wasp new` command.
createNewProject :: Arguments -> Command ()
createNewProject args = do
- newProjectArgs <- parseNewProjectArgs args & either throwProjectCreationError return
- starterTemplates <- liftIO getStarterTemplates
+ newProjectArgs <- parseNewProjectArgs args & either Common.throwProjectCreationError return
+ let starterTemplates = getStarterTemplates
newProjectDescription <- obtainNewProjectDescription newProjectArgs starterTemplates
createProjectOnDisk newProjectDescription
- liftIO $ printGettingStartedInstructions $ _absWaspProjectDir newProjectDescription
+ liftIO $ printGettingStartedInstructionsForProject newProjectDescription
createProjectOnDisk :: NewProjectDescription -> Command ()
createProjectOnDisk
@@ -45,9 +48,18 @@ createProjectOnDisk
} = do
cliSendMessageC $ Msg.Start $ "Creating your project from the \"" ++ show template ++ "\" template..."
case template of
- RemoteStarterTemplate metadata ->
- createProjectOnDiskFromRemoteTemplate absWaspProjectDir projectName appName $ _path metadata
+ GhRepoStarterTemplate ghRepoRef metadata ->
+ createProjectOnDiskFromGhRepoTemplate absWaspProjectDir projectName appName ghRepoRef $ _path metadata
LocalStarterTemplate metadata ->
liftIO $ createProjectOnDiskFromLocalTemplate absWaspProjectDir projectName appName $ _path metadata
AiGeneratedStarterTemplate ->
AI.createNewProjectInteractiveOnDisk absWaspProjectDir appName
+
+-- | This function assumes that the project dir was created inside the current working directory.
+printGettingStartedInstructionsForProject :: NewProjectDescription -> IO ()
+printGettingStartedInstructionsForProject projectDescription = do
+ let projectDirName = init . SP.toFilePath . SP.basename $ _absWaspProjectDir projectDescription
+ let instructions = getTemplateStartingInstructions projectDirName $ _template projectDescription
+ putStrLn $ Term.applyStyles [Term.Green] $ "Created new Wasp app in ./" ++ projectDirName ++ " directory!"
+ putStrLn ""
+ putStrLn instructions
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs
index f4527ab775..aec5371649 100644
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs
@@ -39,8 +39,8 @@ import Wasp.Cli.Command.CreateNewProject.ProjectDescription
parseWaspProjectNameIntoAppName,
)
import Wasp.Cli.Command.CreateNewProject.StarterTemplates (readWaspProjectSkeletonFiles)
-import Wasp.Cli.Common (WaspProjectDir)
import qualified Wasp.Cli.Interactive as Interactive
+import Wasp.Project.Common (WaspProjectDir)
import qualified Wasp.Util as U
import qualified Wasp.Util.Aeson as Utils.Aeson
import qualified Wasp.Util.Terminal as T
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs
index acb0657436..5446040773 100644
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs
@@ -2,17 +2,12 @@ module Wasp.Cli.Command.CreateNewProject.Common
( throwProjectCreationError,
throwInvalidTemplateNameUsedError,
defaultWaspVersionBounds,
- printGettingStartedInstructions,
)
where
import Control.Monad.Except (throwError)
-import StrongPath (Abs, Dir, Path')
-import qualified StrongPath as SP
import Wasp.Cli.Command (Command, CommandError (..))
-import Wasp.Cli.Common (WaspProjectDir)
import qualified Wasp.SemanticVersion as SV
-import qualified Wasp.Util.Terminal as Term
import qualified Wasp.Version as WV
throwProjectCreationError :: String -> Command a
@@ -26,17 +21,3 @@ throwInvalidTemplateNameUsedError =
defaultWaspVersionBounds :: String
defaultWaspVersionBounds = show (SV.backwardsCompatibleWith WV.waspVersion)
-
--- | This function assumes that the project dir is created inside the current working directory
--- when it prints the instructions.
-printGettingStartedInstructions :: Path' Abs (Dir WaspProjectDir) -> IO ()
-printGettingStartedInstructions absProjectDir = do
- let projectFolder = init . SP.toFilePath . SP.basename $ absProjectDir
-{- ORMOLU_DISABLE -}
- putStrLn $ Term.applyStyles [Term.Green] $ "Created new Wasp app in ./" ++ projectFolder ++ " directory!"
- putStrLn "To run it, do:"
- putStrLn ""
- putStrLn $ Term.applyStyles [Term.Bold] $ " cd " ++ projectFolder
- putStrLn $ Term.applyStyles [Term.Bold] " wasp start"
- putStrLn ""
-{- ORMOLU_ENABLE -}
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs
index 2bfb45d4ba..b99046be55 100644
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs
@@ -7,78 +7,227 @@ module Wasp.Cli.Command.CreateNewProject.StarterTemplates
findTemplateByString,
defaultStarterTemplate,
readWaspProjectSkeletonFiles,
+ getTemplateStartingInstructions,
)
where
-import Data.Either (fromRight)
import Data.Foldable (find)
import Data.Text (Text)
-import StrongPath (File', Path, Rel, System, reldir, (>))
-import qualified Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote.Github as Github
-import Wasp.Cli.Common (WaspProjectDir)
+import StrongPath (Dir', File', Path, Path', Rel, Rel', System, reldir, (>))
+import qualified System.FilePath as FP
+import qualified Wasp.Cli.GithubRepo as GhRepo
import qualified Wasp.Cli.Interactive as Interactive
import qualified Wasp.Data as Data
+import Wasp.Project.Common (WaspProjectDir)
import Wasp.Util.IO (listDirectoryDeep, readFileStrict)
+import qualified Wasp.Util.Terminal as Term
data StarterTemplate
- = RemoteStarterTemplate DirBasedTemplateMetadata
- | LocalStarterTemplate DirBasedTemplateMetadata
- | AiGeneratedStarterTemplate
- deriving (Eq)
+ = -- | Template from a Github repo.
+ GhRepoStarterTemplate !GhRepo.GithubRepoRef !DirBasedTemplateMetadata
+ | -- | Template from a disk, that comes bundled with wasp CLI.
+ LocalStarterTemplate !DirBasedTemplateMetadata
+ | -- | Template that will be dynamically generated by Wasp AI based on user's input.
+ AiGeneratedStarterTemplate
data DirBasedTemplateMetadata = DirBasedTemplateMetadata
- { _name :: String,
- _path :: String, -- Path to a directory containing template files.
- _description :: String
+ { _name :: !String,
+ _path :: !(Path' Rel' Dir'), -- Path to a directory containing template files.
+ _description :: !String,
+ _buildStartingInstructions :: !StartingInstructionsBuilder
}
- deriving (Eq, Show)
instance Show StarterTemplate where
- show (RemoteStarterTemplate metadata) = _name metadata
+ show (GhRepoStarterTemplate _ metadata) = _name metadata
show (LocalStarterTemplate metadata) = _name metadata
show AiGeneratedStarterTemplate = "ai-generated"
instance Interactive.IsOption StarterTemplate where
showOption = show
- showOptionDescription (RemoteStarterTemplate metadata) = Just $ _description metadata
+
+ showOptionDescription (GhRepoStarterTemplate _ metadata) = Just $ _description metadata
showOptionDescription (LocalStarterTemplate metadata) = Just $ _description metadata
showOptionDescription AiGeneratedStarterTemplate =
Just "🤖 Describe an app in a couple of sentences and have Wasp AI generate initial code for you. (experimental)"
-getStarterTemplates :: IO [StarterTemplate]
-getStarterTemplates = do
- remoteTemplates <- fromRight [] <$> fetchRemoteStarterTemplates
- return $ localTemplates ++ remoteTemplates ++ [AiGeneratedStarterTemplate]
-
-fetchRemoteStarterTemplates :: IO (Either String [StarterTemplate])
-fetchRemoteStarterTemplates = do
- fmap extractTemplateNames <$> Github.fetchRemoteTemplatesGithubData
- where
- extractTemplateNames :: [Github.RemoteTemplateGithubData] -> [StarterTemplate]
- -- Each folder in the repo is a template.
- extractTemplateNames =
- map
- ( \metadata ->
- RemoteStarterTemplate $
- DirBasedTemplateMetadata
- { _name = Github._name metadata,
- _path = Github._path metadata,
- _description = Github._description metadata
- }
- )
-
-localTemplates :: [StarterTemplate]
-localTemplates = [defaultStarterTemplate]
+type StartingInstructionsBuilder = String -> String
+
+{- HLINT ignore getTemplateStartingInstructions "Redundant $" -}
+
+-- | Returns instructions for running the newly created (from the template) Wasp project.
+-- Instructions assume that user is positioned right next to the just created project directory,
+-- whose name is provided via projectDirName.
+getTemplateStartingInstructions :: String -> StarterTemplate -> String
+getTemplateStartingInstructions projectDirName = \case
+ GhRepoStarterTemplate _ metadata -> _buildStartingInstructions metadata projectDirName
+ LocalStarterTemplate metadata -> _buildStartingInstructions metadata projectDirName
+ AiGeneratedStarterTemplate ->
+ unlines
+ [ styleText $ "To run your new app, do:",
+ styleCode $ " cd " <> projectDirName,
+ styleCode $ " wasp db migrate-dev",
+ styleCode $ " wasp start"
+ ]
+
+getStarterTemplates :: [StarterTemplate]
+getStarterTemplates =
+ [ defaultStarterTemplate,
+ todoTsStarterTemplate,
+ openSaasStarterTemplate,
+ embeddingsStarterTemplate,
+ AiGeneratedStarterTemplate
+ ]
defaultStarterTemplate :: StarterTemplate
-defaultStarterTemplate =
+defaultStarterTemplate = basicStarterTemplate
+
+{- HLINT ignore basicStarterTemplate "Redundant $" -}
+
+basicStarterTemplate :: StarterTemplate
+basicStarterTemplate =
LocalStarterTemplate $
DirBasedTemplateMetadata
- { _name = "basic",
- _path = "basic",
- _description = "Simple starter template with a single page."
+ { _path = [reldir|basic|],
+ _name = "basic",
+ _description = "Simple starter template with a single page.",
+ _buildStartingInstructions = \projectDirName ->
+ unlines
+ [ styleText $ "To run your new app, do:",
+ styleCode $ " cd " <> projectDirName,
+ styleCode $ " wasp db start"
+ ]
}
+{- HLINT ignore openSaasStarterTemplate "Redundant $" -}
+
+openSaasStarterTemplate :: StarterTemplate
+openSaasStarterTemplate =
+ simpleGhRepoTemplate
+ ("open-saas", [reldir|.|])
+ ( "saas",
+ "Everything a SaaS needs! Comes with Auth, ChatGPT API, Tailwind, Stripe payments and more."
+ <> " Check out https://opensaas.sh/ for more details."
+ )
+ ( \projectDirName ->
+ unlines
+ [ styleText $ "To run your new app, follow the instructions below:",
+ styleText $ "",
+ styleText $ " 1. Position into app's root directory:",
+ styleCode $ " cd " <> projectDirName FP.> "app",
+ styleText $ "",
+ styleText $ " 2. Run the development database (and leave it running):",
+ styleCode $ " wasp db start",
+ styleText $ "",
+ styleText $ " 3. Open new terminal window (or tab) in that same dir and continue in it.",
+ styleText $ "",
+ styleText $ " 4. Apply initial database migrations:",
+ styleCode $ " wasp db migrate-dev",
+ styleText $ "",
+ styleText $ " 5. Create initial dot env file from the template:",
+ styleCode $ " cp .env.server.example .env.server",
+ styleText $ "",
+ styleText $ " 6. Last step: run the app!",
+ styleCode $ " wasp start",
+ styleText $ "",
+ styleText $ "Check the README for additional guidance and the link to docs!"
+ ]
+ )
+
+{- HLINT ignore todoTsStarterTemplate "Redundant $" -}
+
+todoTsStarterTemplate :: StarterTemplate
+todoTsStarterTemplate =
+ simpleGhRepoTemplate
+ ("starters", [reldir|todo-ts|])
+ ( "todo-ts",
+ "Simple but well-rounded Wasp app implemented with Typescript & full-stack type safety."
+ )
+ ( \projectDirName ->
+ unlines
+ [ styleText $ "To run your new app, do:",
+ styleCode $ " cd " ++ projectDirName,
+ styleCode $ " wasp db migrate-dev",
+ styleCode $ " wasp start",
+ styleText $ "",
+ styleText $ "Check the README for additional guidance!"
+ ]
+ )
+
+{- Functions for styling instructions. Their names are on purpose of same length, for nicer code formatting. -}
+
+styleCode :: String -> String
+styleCode = Term.applyStyles [Term.Bold]
+
+styleText :: String -> String
+styleText = id
+
+{- -}
+
+{- HLINT ignore embeddingsStarterTemplate "Redundant $" -}
+
+embeddingsStarterTemplate :: StarterTemplate
+embeddingsStarterTemplate =
+ simpleGhRepoTemplate
+ ("starters", [reldir|embeddings|])
+ ( "embeddings",
+ "Comes with code for generating vector embeddings and performing vector similarity search."
+ )
+ ( \projectDirName ->
+ unlines
+ [ styleText $ "To run your new app, follow the instructions below:",
+ styleText $ "",
+ styleText $ " 1. Position into app's root directory:",
+ styleCode $ " cd " <> projectDirName,
+ styleText $ "",
+ styleText $ " 2. Create initial dot env file from the template and fill in your API keys:",
+ styleCode $ " cp .env.server.example .env.server",
+ styleText $ " Fill in your API keys!",
+ styleText $ "",
+ styleText $ " 3. Run the development database (and leave it running):",
+ styleCode $ " wasp db start",
+ styleText $ "",
+ styleText $ " 4. Open new terminal window (or tab) in that same dir and continue in it.",
+ styleText $ "",
+ styleText $ " 5. Apply initial database migrations:",
+ styleCode $ " wasp db migrate-dev",
+ styleText $ "",
+ styleText $ " 6. Run wasp seed script that will generate embeddings from the text files in src/shared/docs:",
+ styleCode $ " wasp db seed",
+ styleText $ "",
+ styleText $ " 7. Last step: run the app!",
+ styleCode $ " wasp start",
+ styleText $ "",
+ styleText $ "Check the README for more detailed instructions and additional guidance!"
+ ]
+ )
+
+simpleGhRepoTemplate :: (String, Path' Rel' Dir') -> (String, String) -> StartingInstructionsBuilder -> StarterTemplate
+simpleGhRepoTemplate (repoName, tmplPathInRepo) (tmplDisplayName, tmplDescription) buildStartingInstructions =
+ GhRepoStarterTemplate
+ ( GhRepo.GithubRepoRef
+ { GhRepo._repoOwner = waspGhOrgName,
+ GhRepo._repoName = repoName,
+ GhRepo._repoReferenceName = waspVersionTemplateGitTag
+ }
+ )
+ ( DirBasedTemplateMetadata
+ { _name = tmplDisplayName,
+ _description = tmplDescription,
+ _path = tmplPathInRepo,
+ _buildStartingInstructions = buildStartingInstructions
+ }
+ )
+
+waspGhOrgName :: String
+waspGhOrgName = "wasp-lang"
+
+-- NOTE: As version of Wasp CLI changes, so we should update this tag name here,
+-- and also create it on gh repos of templates.
+-- By tagging templates for each version of Wasp CLI, we ensure that each release of
+-- Wasp CLI uses correct version of templates, that work with it.
+waspVersionTemplateGitTag :: String
+waspVersionTemplateGitTag = "wasp-v0.12-template"
+
findTemplateByString :: [StarterTemplate] -> String -> Maybe StarterTemplate
findTemplateByString templates query = find ((== query) . show) templates
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/GhRepo.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/GhRepo.hs
new file mode 100644
index 0000000000..e859f74fbe
--- /dev/null
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/GhRepo.hs
@@ -0,0 +1,27 @@
+module Wasp.Cli.Command.CreateNewProject.StarterTemplates.GhRepo
+ ( createProjectOnDiskFromGhRepoTemplate,
+ )
+where
+
+import Control.Monad.IO.Class (liftIO)
+import StrongPath (Abs, Dir, Dir', Path', Rel')
+import Wasp.Cli.Command (Command)
+import Wasp.Cli.Command.CreateNewProject.Common (throwProjectCreationError)
+import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName)
+import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating (replaceTemplatePlaceholdersInWaspFile)
+import Wasp.Cli.GithubRepo (GithubRepoRef, fetchFolderFromGithubRepoToDisk)
+import Wasp.Project (WaspProjectDir)
+
+createProjectOnDiskFromGhRepoTemplate ::
+ Path' Abs (Dir WaspProjectDir) ->
+ NewProjectName ->
+ NewProjectAppName ->
+ GithubRepoRef ->
+ Path' Rel' Dir' ->
+ Command ()
+createProjectOnDiskFromGhRepoTemplate absWaspProjectDir projectName appName ghRepoRef templatePathInRepo = do
+ fetchTheTemplateFromGhToDisk >>= either throwProjectCreationError pure
+ liftIO $ replaceTemplatePlaceholdersInWaspFile appName projectName absWaspProjectDir
+ where
+ fetchTheTemplateFromGhToDisk = do
+ liftIO $ fetchFolderFromGithubRepoToDisk ghRepoRef templatePathInRepo absWaspProjectDir
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs
index 655e88815e..ec3e85303f 100644
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs
@@ -3,10 +3,8 @@ module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local
)
where
-import Data.Maybe (fromJust)
import Path.IO (copyDirRecur)
-import StrongPath (Abs, Dir, Path', reldir, (>))
-import qualified StrongPath as SP
+import StrongPath (Abs, Dir, Dir', Path', Rel', reldir, (>))
import StrongPath.Path (toPathAbsDir)
import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName)
import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating (replaceTemplatePlaceholdersInWaspFile)
@@ -14,16 +12,16 @@ import qualified Wasp.Data as Data
import Wasp.Project (WaspProjectDir)
createProjectOnDiskFromLocalTemplate ::
- Path' Abs (Dir WaspProjectDir) -> NewProjectName -> NewProjectAppName -> String -> IO ()
+ Path' Abs (Dir WaspProjectDir) -> NewProjectName -> NewProjectAppName -> Path' Rel' Dir' -> IO ()
createProjectOnDiskFromLocalTemplate absWaspProjectDir projectName appName templatePath = do
copyLocalTemplateToNewProjectDir templatePath
replaceTemplatePlaceholdersInWaspFile appName projectName absWaspProjectDir
where
- copyLocalTemplateToNewProjectDir :: String -> IO ()
+ copyLocalTemplateToNewProjectDir :: Path' Rel' Dir' -> IO ()
copyLocalTemplateToNewProjectDir templateDir = do
dataDir <- Data.getAbsDataDirPath
let absLocalTemplateDir =
- dataDir > [reldir|Cli/templates|] > (fromJust . SP.parseRelDir $ templateDir)
+ dataDir > [reldir|Cli/templates|] > templateDir
let absSkeletonTemplateDir =
dataDir > [reldir|Cli/templates/skeleton|]
-- First we copy skeleton files, which form the basis of any Wasp project,
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote.hs
deleted file mode 100644
index 7317a38866..0000000000
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote.hs
+++ /dev/null
@@ -1,31 +0,0 @@
-module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote
- ( createProjectOnDiskFromRemoteTemplate,
- )
-where
-
-import Control.Monad.IO.Class (liftIO)
-import Data.Maybe (fromJust)
-import StrongPath (Abs, Dir, Path')
-import qualified StrongPath as SP
-import Wasp.Cli.Command (Command)
-import Wasp.Cli.Command.CreateNewProject.Common (throwProjectCreationError)
-import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName)
-import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote.Github (starterTemplateGithubRepo)
-import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating (replaceTemplatePlaceholdersInWaspFile)
-import Wasp.Cli.GithubRepo (fetchFolderFromGithubRepoToDisk)
-import Wasp.Project (WaspProjectDir)
-
-createProjectOnDiskFromRemoteTemplate ::
- Path' Abs (Dir WaspProjectDir) ->
- NewProjectName ->
- NewProjectAppName ->
- String ->
- Command ()
-createProjectOnDiskFromRemoteTemplate absWaspProjectDir projectName appName templatePath = do
- fetchGithubTemplateToDisk absWaspProjectDir templatePath >>= either throwProjectCreationError pure
- liftIO $ replaceTemplatePlaceholdersInWaspFile appName projectName absWaspProjectDir
- where
- fetchGithubTemplateToDisk :: Path' Abs (Dir WaspProjectDir) -> String -> Command (Either String ())
- fetchGithubTemplateToDisk projectDir templatePathInRepo = do
- let templateFolderPath = fromJust . SP.parseRelDir $ templatePathInRepo
- liftIO $ fetchFolderFromGithubRepoToDisk starterTemplateGithubRepo templateFolderPath projectDir
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote/Github.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote/Github.hs
deleted file mode 100644
index db93fe6578..0000000000
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote/Github.hs
+++ /dev/null
@@ -1,36 +0,0 @@
-module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote.Github where
-
-import Data.Aeson (FromJSON (parseJSON), withObject, (.:))
-import Wasp.Cli.GithubRepo (GithubRepoRef (..))
-import qualified Wasp.Cli.GithubRepo as GR
-
-starterTemplateGithubRepo :: GithubRepoRef
-starterTemplateGithubRepo =
- GithubRepoRef
- { _repoOwner = "wasp-lang",
- _repoName = "starters",
- _repoReferenceName = "main"
- }
-
-starterTemplatesDataGithubFilePath :: FilePath
-starterTemplatesDataGithubFilePath = "templates.json"
-
-fetchRemoteTemplatesGithubData :: IO (Either String [RemoteTemplateGithubData])
-fetchRemoteTemplatesGithubData = GR.fetchRepoFileContents starterTemplateGithubRepo starterTemplatesDataGithubFilePath
-
-data RemoteTemplateGithubData = RemoteTemplateGithubData
- { _name :: String,
- _description :: String,
- _path :: String
- }
- deriving (Show, Eq)
-
-instance FromJSON RemoteTemplateGithubData where
- parseJSON = withObject "RemoteTemplateGithubData" $ \obj ->
- RemoteTemplateGithubData
- <$> obj
- .: "name"
- <*> obj
- .: "description"
- <*> obj
- .: "path"
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs
index 02eac1ba0b..8347151624 100644
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs
@@ -3,20 +3,26 @@ module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating where
import Data.List (foldl')
import Data.Text (Text)
import qualified Data.Text as T
-import StrongPath (Abs, Dir, File, Path', relfile, (>))
+import StrongPath (Abs, Dir, File, Path')
import Wasp.Cli.Command.CreateNewProject.Common (defaultWaspVersionBounds)
import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName)
+import Wasp.Project.Analyze (findWaspFile)
import Wasp.Project.Common (WaspProjectDir)
import qualified Wasp.Util.IO as IOUtil
--- Template file for wasp file has placeholders in it that we want to replace
+-- | Template file for wasp file has placeholders in it that we want to replace
-- in the .wasp file we have written to the disk.
-replaceTemplatePlaceholdersInWaspFile :: NewProjectAppName -> NewProjectName -> Path' Abs (Dir WaspProjectDir) -> IO ()
-replaceTemplatePlaceholdersInWaspFile appName projectName projectDir =
- updateFileContent absMainWaspFile $ replacePlaceholders waspFileReplacements
+-- If no .wasp file was found in the project, do nothing.
+replaceTemplatePlaceholdersInWaspFile ::
+ NewProjectAppName -> NewProjectName -> Path' Abs (Dir WaspProjectDir) -> IO ()
+replaceTemplatePlaceholdersInWaspFile appName projectName projectDir = do
+ findWaspFile projectDir >>= \case
+ Nothing -> return ()
+ Just absMainWaspFile ->
+ updateFileContentWith absMainWaspFile (replacePlaceholders waspFileReplacements)
where
- updateFileContent :: Path' Abs (File f) -> (Text -> Text) -> IO ()
- updateFileContent absFilePath updateFn =
+ updateFileContentWith :: Path' Abs (File f) -> (Text -> Text) -> IO ()
+ updateFileContentWith absFilePath updateFn =
IOUtil.readFileStrict absFilePath >>= IOUtil.writeFileFromText absFilePath . updateFn
replacePlaceholders :: [(String, String)] -> Text -> Text
@@ -24,7 +30,6 @@ replaceTemplatePlaceholdersInWaspFile appName projectName projectDir =
where
replacePlaceholder content' (placeholder, value) = T.replace (T.pack placeholder) (T.pack value) content'
- absMainWaspFile = projectDir > [relfile|main.wasp|]
waspFileReplacements =
[ ("__waspAppName__", show appName),
("__waspProjectName__", show projectName),
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs
index ee6bc64d78..5f4e21c7d5 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs
@@ -10,11 +10,11 @@ import StrongPath (Abs, Dir, Path', (>))
import Wasp.Cli.Command (Command, CommandError (..))
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Cli.Common
import Wasp.Generator.Common (ProjectRootDir)
import Wasp.Generator.DbGenerator.Common (MigrateArgs (..), defaultMigrateArgs)
import qualified Wasp.Generator.DbGenerator.Operations as DbOps
import qualified Wasp.Message as Msg
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
import Wasp.Project.Db.Migrations (DbMigrationsDir, dbMigrationsDirInWaspProjectDir)
-- | NOTE(shayne): Performs database schema migration (based on current schema) in the generated project.
@@ -26,8 +26,8 @@ migrateDev optionalMigrateArgs = do
let waspDbMigrationsDir = waspProjectDir > dbMigrationsDirInWaspProjectDir
let projectRootDir =
waspProjectDir
- > Cli.Common.dotWaspDirInWaspProjectDir
- > Cli.Common.generatedCodeDirInDotWaspDir
+ > dotWaspDirInWaspProjectDir
+ > generatedCodeDirInDotWaspDir
migrateDatabase optionalMigrateArgs projectRootDir waspDbMigrationsDir
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs
index 9220a671d8..a293636b5c 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs
@@ -8,15 +8,15 @@ import StrongPath ((>))
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Common
import Wasp.Generator.DbGenerator.Operations (dbReset)
import qualified Wasp.Message as Msg
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
reset :: Command ()
reset = do
InWaspProject waspProjectDir <- require
let genProjectDir =
- waspProjectDir > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+ waspProjectDir > dotWaspDirInWaspProjectDir > generatedCodeDirInDotWaspDir
cliSendMessageC $ Msg.Start "Resetting the database..."
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs
index ee7dfb85d7..176962f097 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs
@@ -19,15 +19,15 @@ import Wasp.Cli.Command (Command, CommandError (CommandError))
import Wasp.Cli.Command.Compile (analyze)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Common
import Wasp.Generator.DbGenerator.Operations (dbSeed)
import qualified Wasp.Message as Msg
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
seed :: Maybe String -> Command ()
seed maybeUserProvidedSeedName = do
InWaspProject waspProjectDir <- require
let genProjectDir =
- waspProjectDir > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+ waspProjectDir > dotWaspDirInWaspProjectDir > generatedCodeDirInDotWaspDir
appSpec <- analyze waspProjectDir
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs
index 708f2c31ff..09db8ca458 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs
@@ -10,16 +10,16 @@ import StrongPath ((>))
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Common
import Wasp.Generator.DbGenerator.Jobs (runStudio)
import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed)
import qualified Wasp.Message as Msg
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
studio :: Command ()
studio = do
InWaspProject waspProjectDir <- require
let genProjectDir =
- waspProjectDir > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+ waspProjectDir > dotWaspDirInWaspProjectDir > generatedCodeDirInDotWaspDir
cliSendMessageC $ Msg.Start "Running studio..."
diff --git a/waspc/cli/src/Wasp/Cli/Command/Require.hs b/waspc/cli/src/Wasp/Cli/Command/Require.hs
index a640c7d64b..f4d072893c 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Require.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Require.hs
@@ -37,9 +37,9 @@ import qualified StrongPath as SP
import System.Directory (doesFileExist, doesPathExist, getCurrentDirectory)
import qualified System.FilePath as FP
import Wasp.Cli.Command (CommandError (CommandError), Requirable (checkRequirement), require)
-import Wasp.Cli.Common (WaspProjectDir)
-import qualified Wasp.Cli.Common as Cli.Common
import Wasp.Generator.DbGenerator.Operations (isDbConnectionPossible, testDbConnection)
+import Wasp.Project.Common (WaspProjectDir)
+import qualified Wasp.Project.Common as Project.Common
data DbConnectionEstablished = DbConnectionEstablished deriving (Typeable)
@@ -48,7 +48,10 @@ instance Requirable DbConnectionEstablished where
-- NOTE: 'InWaspProject' does not depend on this requirement, so this
-- call to 'require' will not result in an infinite loop.
InWaspProject waspProjectDir <- require
- let outDir = waspProjectDir SP.> Cli.Common.dotWaspDirInWaspProjectDir SP.> Cli.Common.generatedCodeDirInDotWaspDir
+ let outDir =
+ waspProjectDir
+ SP.> Project.Common.dotWaspDirInWaspProjectDir
+ SP.> Project.Common.generatedCodeDirInDotWaspDir
dbIsRunning <- liftIO $ isDbConnectionPossible <$> testDbConnection outDir
if dbIsRunning
@@ -82,7 +85,7 @@ instance Requirable InWaspProject where
let absCurrentDirFp = SP.fromAbsDir currentDir
doesCurrentDirExist <- liftIO $ doesPathExist absCurrentDirFp
unless doesCurrentDirExist (throwError notFoundError)
- let dotWaspRootFilePath = absCurrentDirFp FP.> SP.fromRelFile Cli.Common.dotWaspRootFileInWaspProjectDir
+ let dotWaspRootFilePath = absCurrentDirFp FP.> SP.fromRelFile Project.Common.dotWaspRootFileInWaspProjectDir
isCurrentDirRoot <- liftIO $ doesFileExist dotWaspRootFilePath
if isCurrentDirRoot
then return $ InWaspProject $ SP.castDir currentDir
diff --git a/waspc/cli/src/Wasp/Cli/Command/Start.hs b/waspc/cli/src/Wasp/Cli/Command/Start.hs
index b1cc33f385..eb0f99536a 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Start.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Start.hs
@@ -14,17 +14,17 @@ import Wasp.Cli.Command.Compile (compile, printWarningsAndErrorsIfAny)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (DbConnectionEstablished (DbConnectionEstablished), InWaspProject (InWaspProject), require)
import Wasp.Cli.Command.Watch (watch)
-import qualified Wasp.Cli.Common as Common
import qualified Wasp.Generator
import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning)
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
-- | Does initial compile of wasp code and then runs the generated project.
-- It also listens for any file changes and recompiles and restarts generated project accordingly.
start :: Command ()
start = do
InWaspProject waspRoot <- require
- let outDir = waspRoot > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+ let outDir = waspRoot > dotWaspDirInWaspProjectDir > generatedCodeDirInDotWaspDir
cliSendMessageC $ Msg.Start "Starting compilation and setup phase. Hold tight..."
diff --git a/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs b/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs
index eb2b3baaa5..7807418264 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs
@@ -22,8 +22,8 @@ import Wasp.Cli.Command.Common (throwIfExeIsNotAvailable)
import Wasp.Cli.Command.Compile (analyze)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import Wasp.Cli.Common (WaspProjectDir)
import qualified Wasp.Message as Msg
+import Wasp.Project.Common (WaspProjectDir)
import Wasp.Project.Db (databaseUrlEnvVarName)
import Wasp.Project.Db.Dev (makeDevDbUniqueId)
import qualified Wasp.Project.Db.Dev.Postgres as Dev.Postgres
diff --git a/waspc/cli/src/Wasp/Cli/Command/Studio.hs b/waspc/cli/src/Wasp/Cli/Command/Studio.hs
index 48ec59d4cd..f3929496bb 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Studio.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Studio.hs
@@ -29,8 +29,8 @@ import Wasp.Cli.Command (Command, CommandError (CommandError))
import Wasp.Cli.Command.Compile (analyze)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
-import qualified Wasp.Cli.Common as Common
import qualified Wasp.Message as Msg
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
import qualified Wasp.Project.Studio
studio :: Command ()
@@ -123,8 +123,8 @@ studio = do
]
let generatedProjectDir =
- waspDir > Common.dotWaspDirInWaspProjectDir
- > Common.generatedCodeDirInDotWaspDir
+ waspDir > dotWaspDirInWaspProjectDir
+ > generatedCodeDirInDotWaspDir
let waspStudioDataJsonFilePath = generatedProjectDir > [relfile|.wasp-studio-data.json|]
liftIO $ do
diff --git a/waspc/cli/src/Wasp/Cli/Command/Test.hs b/waspc/cli/src/Wasp/Cli/Command/Test.hs
index 2e8a761aa5..e9378bfbce 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Test.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Test.hs
@@ -14,10 +14,10 @@ import Wasp.Cli.Command.Compile (compile)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
import Wasp.Cli.Command.Watch (watch)
-import qualified Wasp.Cli.Common as Common
import qualified Wasp.Generator
import Wasp.Generator.Common (ProjectRootDir)
import qualified Wasp.Message as Msg
+import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
test :: [String] -> Command ()
test [] = throwError $ CommandError "Not enough arguments" "Expected: wasp test client "
@@ -28,7 +28,7 @@ test _ = throwError $ CommandError "Invalid arguments" "Expected: wasp test clie
watchAndTest :: (Path' Abs (Dir ProjectRootDir) -> IO (Either String ())) -> Command ()
watchAndTest testRunner = do
InWaspProject waspRoot <- require
- let outDir = waspRoot > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+ let outDir = waspRoot > dotWaspDirInWaspProjectDir > generatedCodeDirInDotWaspDir
cliSendMessageC $ Msg.Start "Starting compilation and setup phase. Hold tight..."
diff --git a/waspc/cli/src/Wasp/Cli/Command/Watch.hs b/waspc/cli/src/Wasp/Cli/Command/Watch.hs
index 5689e0be56..2da3e77d19 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Watch.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Watch.hs
@@ -15,11 +15,11 @@ import qualified StrongPath as SP
import qualified System.FSNotify as FSN
import qualified System.FilePath as FP
import Wasp.Cli.Command.Compile (compileIO, printCompilationResult)
-import qualified Wasp.Cli.Common as Common
import Wasp.Cli.Message (cliSendMessage)
import qualified Wasp.Generator.Common as Wasp.Generator
import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
+import qualified Wasp.Project.Common as ProjectCommon
-- TODO: Idea: Read .gitignore file, and ignore everything from it. This will then also cover the
-- .wasp dir, and users can easily add any custom stuff they want ignored. But, we also have to
@@ -41,9 +41,9 @@ watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mg
chan <- newChan
_ <- FSN.watchDirChan mgr (SP.fromAbsDir waspProjectDir) eventFilter chan
let watchProjectSubdirTree path = FSN.watchTreeChan mgr (SP.fromAbsDir $ waspProjectDir > path) eventFilter chan
- _ <- watchProjectSubdirTree Common.extClientCodeDirInWaspProjectDir
- _ <- watchProjectSubdirTree Common.extServerCodeDirInWaspProjectDir
- _ <- watchProjectSubdirTree Common.extSharedCodeDirInWaspProjectDir
+ -- todo(filip): check if this still works
+ _ <- watchProjectSubdirTree ProjectCommon.extCodeDirInWaspProjectDir
+ _ <- watchProjectSubdirTree ProjectCommon.extPublicDirInWaspProjectDir
listenForEvents chan currentTime
where
listenForEvents :: Chan FSN.Event -> UTCTime -> IO ()
diff --git a/waspc/cli/src/Wasp/Cli/Common.hs b/waspc/cli/src/Wasp/Cli/Common.hs
index 282cf40dfe..2fe6841615 100644
--- a/waspc/cli/src/Wasp/Cli/Common.hs
+++ b/waspc/cli/src/Wasp/Cli/Common.hs
@@ -1,57 +1,15 @@
module Wasp.Cli.Common
- ( WaspProjectDir,
- DotWaspDir,
- CliTemplatesDir,
- dotWaspDirInWaspProjectDir,
- dotWaspRootFileInWaspProjectDir,
- dotWaspInfoFileInGeneratedCodeDir,
- extServerCodeDirInWaspProjectDir,
- extClientCodeDirInWaspProjectDir,
- extSharedCodeDirInWaspProjectDir,
- generatedCodeDirInDotWaspDir,
- buildDirInDotWaspDir,
+ ( CliTemplatesDir,
waspSays,
waspWarns,
waspScreams,
)
where
-import StrongPath (Dir, File', Path', Rel, reldir, relfile)
-import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir)
-import qualified Wasp.Generator.Common
-import Wasp.Project (WaspProjectDir)
import qualified Wasp.Util.Terminal as Term
-data DotWaspDir -- Here we put everything that wasp generates.
-
data CliTemplatesDir
--- TODO: SHould this be renamed to include word "root"?
-dotWaspDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir DotWaspDir)
-dotWaspDirInWaspProjectDir = [reldir|.wasp|]
-
--- TODO: Hm this has different name than it has in Generator.
-generatedCodeDirInDotWaspDir :: Path' (Rel DotWaspDir) (Dir Wasp.Generator.Common.ProjectRootDir)
-generatedCodeDirInDotWaspDir = [reldir|out|]
-
-buildDirInDotWaspDir :: Path' (Rel DotWaspDir) (Dir Wasp.Generator.Common.ProjectRootDir)
-buildDirInDotWaspDir = [reldir|build|]
-
-dotWaspRootFileInWaspProjectDir :: Path' (Rel WaspProjectDir) File'
-dotWaspRootFileInWaspProjectDir = [relfile|.wasproot|]
-
-dotWaspInfoFileInGeneratedCodeDir :: Path' (Rel Wasp.Generator.Common.ProjectRootDir) File'
-dotWaspInfoFileInGeneratedCodeDir = [relfile|.waspinfo|]
-
-extServerCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir)
-extServerCodeDirInWaspProjectDir = [reldir|src/server|]
-
-extClientCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir)
-extClientCodeDirInWaspProjectDir = [reldir|src/client|]
-
-extSharedCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir)
-extSharedCodeDirInWaspProjectDir = [reldir|src/shared|]
-
waspSays :: String -> IO ()
waspSays what = putStrLn $ Term.applyStyles [Term.Yellow] what
diff --git a/waspc/data/Cli/templates/basic/package.json b/waspc/data/Cli/templates/basic/package.json
new file mode 100644
index 0000000000..f6d2cca6fa
--- /dev/null
+++ b/waspc/data/Cli/templates/basic/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "prototype",
+ "dependencies": {
+ "wasp": "file:.wasp/out/sdk/wasp",
+ "react": "^18.2.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.1.0",
+ "vite": "^4.3.9",
+ "@types/react": "^18.0.37",
+ "prisma": "4.16.2"
+ },
+ "workspaces": [
+ ".wasp/out/sdk/wasp",
+ ".wasp/out/web-app",
+ ".wasp/out/server"
+ ],
+ "//": [
+ "COMMENTS:",
+ {
+ "devDependencies.prisma": [
+ "We on purpose specify exact version for prisma.",
+ "Check this GH comment for the reasoning behind it:",
+ "https://github.com/wasp-lang/wasp/pull/634#issuecomment-1158802302 ."
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/waspc/data/Generator/templates/Dockerfile b/waspc/data/Generator/templates/Dockerfile
index 8194f2916f..23833aabee 100644
--- a/waspc/data/Generator/templates/Dockerfile
+++ b/waspc/data/Generator/templates/Dockerfile
@@ -26,7 +26,7 @@ COPY server/ ./server/
RUN cd server && npm install
{=# usingPrisma =}
COPY db/schema.prisma ./db/
-RUN cd server && {= serverPrismaClientOutputDirEnv =} npx prisma generate --schema='{= dbSchemaFileFromServerDir =}'
+RUN cd server && npx prisma generate --schema='{= dbSchemaFileFromServerDir =}'
{=/ usingPrisma =}
# Building the server should come after Prisma generation.
RUN cd server && npm run build
diff --git a/waspc/data/Generator/templates/db/schema.prisma b/waspc/data/Generator/templates/db/schema.prisma
index ababaed1fe..d56db09e21 100644
--- a/waspc/data/Generator/templates/db/schema.prisma
+++ b/waspc/data/Generator/templates/db/schema.prisma
@@ -10,7 +10,6 @@ datasource db {
generator client {
provider = "prisma-client-js"
- output = {=& prismaClientOutputDir =}
{=# prismaPreviewFeatures =}
previewFeatures = {=& . =}
{=/ prismaPreviewFeatures =}
diff --git a/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs b/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs
index 27d6a9fd59..18ee507c9e 100644
--- a/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs
+++ b/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs
@@ -1,4 +1,4 @@
-import { throwIfNotValidAbsoluteURL } from './universal/validators.mjs';
+import { throwIfNotValidAbsoluteURL } from 'wasp/universal/validators';
console.info("🔍 Validating environment variables...");
throwIfNotValidAbsoluteURL(process.env.REACT_APP_API_URL, 'Environemnt variable REACT_APP_API_URL');
diff --git a/waspc/data/Generator/templates/react-app/src/actions/index.ts b/waspc/data/Generator/templates/react-app/src/actions/index.ts
index 5e4dfedd12..7fb2de2f9e 100644
--- a/waspc/data/Generator/templates/react-app/src/actions/index.ts
+++ b/waspc/data/Generator/templates/react-app/src/actions/index.ts
@@ -42,7 +42,7 @@ export type UpdateQuery = (item: ActionInput, oldData:
/**
* A public query specifier used for addressing Wasp queries. See our docs for details:
- * https://wasp-lang.dev/docs/language/features#the-useaction-hook.
+ * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
*/
export type QuerySpecifier = [Query, ...any[]]
@@ -116,7 +116,7 @@ type InternalAction = Action & {
*
* @param publicOptimisticUpdateDefinition An optimistic update definition
* object that's a part of the public API:
- * https://wasp-lang.dev/docs/language/features#the-useaction-hook.
+ * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
* @returns An internally-used optimistic update definition object.
*/
function translateToInternalDefinition- (
@@ -260,7 +260,7 @@ function getOptimisticUpdateDefinitionForSpecificItem(
* Translates a Wasp query specifier to a query cache key used by React Query.
*
* @param querySpecifier A query specifier that's a part of the public API:
- * https://wasp-lang.dev/docs/language/features#the-useaction-hook.
+ * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
* @returns A cache key React Query internally uses for addressing queries.
*/
function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier): QueryKey {
diff --git a/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts b/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts
index c287486ef5..dafb5d9ac9 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts
@@ -5,7 +5,7 @@ import { initSession } from '../../helpers/user';
export async function login(data: { email: string; password: string }): Promise {
try {
const response = await api.post('{= loginPath =}', data);
- await initSession(response.data.token);
+ await initSession(response.data.sessionId);
} catch (e) {
handleApiError(e);
}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx
index 9ec80aa6f1..8fd6348c58 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx
@@ -166,12 +166,6 @@ export const LoginSignupForm = ({
onLoginSuccess() {
history.push('{= onAuthSucceededRedirectTo =}')
},
- {=# isEmailVerificationRequired =}
- isEmailVerificationRequired: true,
- {=/ isEmailVerificationRequired =}
- {=^ isEmailVerificationRequired =}
- isEmailVerificationRequired: false,
- {=/ isEmailVerificationRequired =}
});
{=/ isEmailAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts
index f5f4e371c0..4d8b792ba0 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts
@@ -4,7 +4,6 @@ import { login } from '../../../email/actions/login'
export function useEmail({
onError,
showEmailVerificationPending,
- isEmailVerificationRequired,
onLoginSuccess,
isLogin,
}: {
@@ -12,7 +11,6 @@ export function useEmail({
showEmailVerificationPending: () => void
onLoginSuccess: () => void
isLogin: boolean
- isEmailVerificationRequired: boolean
}) {
async function handleSubmit(data) {
try {
@@ -21,12 +19,7 @@ export function useEmail({
onLoginSuccess()
} else {
await signup(data)
- if (isEmailVerificationRequired) {
- showEmailVerificationPending()
- } else {
- await login(data)
- onLoginSuccess()
- }
+ showEmailVerificationPending()
}
} catch (err: unknown) {
onError(err as Error)
diff --git a/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts b/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts
index 1c6fc500f4..a6b06299ce 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts
@@ -1,8 +1,8 @@
-import { setAuthToken } from '../../api'
+import { setSessionId } from '../../api'
import { invalidateAndRemoveQueries } from '../../operations/resources'
-export async function initSession(token: string): Promise {
- setAuthToken(token)
+export async function initSession(sessionId: string): Promise {
+ setSessionId(sessionId)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.
diff --git a/waspc/data/Generator/templates/react-app/src/auth/login.ts b/waspc/data/Generator/templates/react-app/src/auth/login.ts
index 9bc9ceef53..aa808309f3 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/login.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/login.ts
@@ -7,7 +7,7 @@ export default async function login(username: string, password: string): Promise
const args = { username, password }
const response = await api.post('{= loginPath =}', args)
- await initSession(response.data.token)
+ await initSession(response.data.sessionId)
} catch (error) {
handleApiError(error)
}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/logout.ts b/waspc/data/Generator/templates/react-app/src/auth/logout.ts
index 44b9e05c33..715f99a49b 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/logout.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/logout.ts
@@ -1,9 +1,17 @@
-import { removeLocalUserData } from '../api'
+import api, { removeLocalUserData } from '../api'
import { invalidateAndRemoveQueries } from '../operations/resources'
export default async function logout(): Promise {
- removeLocalUserData()
- // TODO(filip): We are currently invalidating and removing all the queries, but
- // we should remove only the non-public, user-dependent ones.
- await invalidateAndRemoveQueries()
+ try {
+ await api.post('/auth/logout')
+ } finally {
+ // Even if the logout request fails, we still want to remove the local user data
+ // in case the logout failed because of a network error and the user walked away
+ // from the computer.
+ removeLocalUserData()
+
+ // TODO(filip): We are currently invalidating and removing all the queries, but
+ // we should remove only the non-public, user-dependent ones.
+ await invalidateAndRemoveQueries()
+ }
}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx
index fdc0fbbcc0..10b3ed4d44 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx
+++ b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx
@@ -29,7 +29,7 @@ export default function OAuthCodeExchange({ pathToApiServerRouteHandlingOauthRed
// This helps us reuse one component for various methods (e.g., Google, Facebook, etc.).
const apiServerUrlHandlingOauthRedirect = constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect)
- exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect)
+ exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect)
return () => {
firstRender.current = false
}
@@ -47,22 +47,22 @@ function constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRed
return `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}`
}
-async function exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) {
- const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect)
+async function exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect) {
+ const sessionId = await exchangeCodeForSessionId(apiServerUrlHandlingOauthRedirect)
- if (token !== null) {
- await initSession(token)
+ if (sessionId !== null) {
+ await initSession(sessionId)
history.push('{= onAuthSucceededRedirectTo =}')
} else {
- console.error('Error obtaining JWT token')
+ console.error('Error obtaining session ID')
history.push('{= onAuthFailedRedirectTo =}')
}
}
-async function exchangeCodeForJwt(url) {
+async function exchangeCodeForSessionId(url) {
try {
const response = await api.get(url)
- return response?.data?.token || null
+ return response?.data?.sessionId || null
} catch (e) {
console.error(e)
return null
diff --git a/waspc/data/Generator/templates/react-app/src/auth/types.ts b/waspc/data/Generator/templates/react-app/src/auth/types.ts
index 4405410cc7..637a2e13d4 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/types.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/types.ts
@@ -1,2 +1,2 @@
// todo(filip): turn into a proper import/path
-export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../../../server/src/_types/'
+export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../../../server/src/_types/'
diff --git a/waspc/data/Generator/templates/react-app/src/auth/user.ts b/waspc/data/Generator/templates/react-app/src/auth/user.ts
index 5799c71ea7..aa0da24824 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/user.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/user.ts
@@ -2,7 +2,7 @@
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
-import type { User, ProviderName, DeserializedAuthEntity } from './types'
+import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
@@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null {
return user.auth.identities[0].providerUserId ?? null;
}
-export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined {
+export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);
diff --git a/waspc/data/Generator/templates/react-app/src/index.tsx b/waspc/data/Generator/templates/react-app/src/index.tsx
index dc0c1171ed..da29899438 100644
--- a/waspc/data/Generator/templates/react-app/src/index.tsx
+++ b/waspc/data/Generator/templates/react-app/src/index.tsx
@@ -7,7 +7,7 @@ import router from './router'
import {
initializeQueryClient,
queryClientInitialized,
-} from './queryClient'
+} from 'wasp/rpc/queryClient'
{=# setupFn.isDefined =}
{=& setupFn.importStatement =}
diff --git a/waspc/data/Generator/templates/react-app/src/queries/core.d.ts b/waspc/data/Generator/templates/react-app/src/queries/core.d.ts
index e1bdbe4783..90e30187a9 100644
--- a/waspc/data/Generator/templates/react-app/src/queries/core.d.ts
+++ b/waspc/data/Generator/templates/react-app/src/queries/core.d.ts
@@ -1,6 +1,6 @@
import { type Query } from '.'
import { Route } from '../types';
-import type { Expand, _Awaited, _ReturnType } from '../universal/types'
+import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types'
export function createQuery(
queryRoute: string,
diff --git a/waspc/data/Generator/templates/react-app/src/router.tsx b/waspc/data/Generator/templates/react-app/src/router.tsx
index 1c290df6d0..ed1de164a0 100644
--- a/waspc/data/Generator/templates/react-app/src/router.tsx
+++ b/waspc/data/Generator/templates/react-app/src/router.tsx
@@ -12,7 +12,7 @@ import type {
{=/ rootComponent.isDefined =}
{=# isAuthEnabled =}
-import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
+import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage"
{=/ isAuthEnabled =}
{=# pagesToImport =}
diff --git a/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx b/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx
index 10e4aa1e96..ef6e4b1e2a 100644
--- a/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx
+++ b/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx
@@ -2,7 +2,7 @@
import { createContext, useState, useEffect } from 'react'
import { io, Socket } from 'socket.io-client'
-import { getAuthToken } from '../api'
+import { getSessionId } from '../api'
import { apiEventsEmitter } from '../api/events'
import config from '../config'
@@ -16,7 +16,7 @@ function refreshAuthToken() {
// NOTE: When we figure out how `auth: true` works for Operations, we should
// mirror that behavior here for WebSockets. Ref: https://github.com/wasp-lang/wasp/issues/1133
socket.auth = {
- token: getAuthToken()
+ sessionId: getSessionId()
}
if (socket.connected) {
@@ -26,8 +26,8 @@ function refreshAuthToken() {
}
refreshAuthToken()
-apiEventsEmitter.on('authToken.set', refreshAuthToken)
-apiEventsEmitter.on('authToken.clear', refreshAuthToken)
+apiEventsEmitter.on('sessionId.set', refreshAuthToken)
+apiEventsEmitter.on('sessionId.clear', refreshAuthToken)
export const WebSocketContext = createContext({
socket,
diff --git a/waspc/data/Generator/templates/react-app/tsconfig.json b/waspc/data/Generator/templates/react-app/tsconfig.json
index 968a1bb47f..263338d1ce 100644
--- a/waspc/data/Generator/templates/react-app/tsconfig.json
+++ b/waspc/data/Generator/templates/react-app/tsconfig.json
@@ -8,6 +8,12 @@
// Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true
},
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
-}
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/waspc/data/Generator/templates/react-app/vite.config.ts b/waspc/data/Generator/templates/react-app/vite.config.ts
index 31e1055ddb..8bc0157ed7 100644
--- a/waspc/data/Generator/templates/react-app/vite.config.ts
+++ b/waspc/data/Generator/templates/react-app/vite.config.ts
@@ -1,7 +1,7 @@
{{={= =}=}}
///
import { mergeConfig } from "vite";
-import react from "@vitejs/plugin-react-swc";
+import react from "@vitejs/plugin-react";
{=# customViteConfig.isDefined =}
{=& customViteConfig.importStatement =}
@@ -14,6 +14,9 @@ const _waspUserProvidedConfig = {};
const defaultViteConfig = {
base: "{= baseDir =}",
plugins: [react()],
+ optimizeDeps: {
+ exclude: ['wasp']
+ },
server: {
port: {= defaultClientPort =},
host: "0.0.0.0",
@@ -27,6 +30,9 @@ const defaultViteConfig = {
environment: "jsdom",
setupFiles: ["./src/test/vitest/setup.ts"],
},
+ // resolve: {
+ // dedupe: ["react", "react-dom"],
+ // },
};
// https://vitejs.dev/config/
diff --git a/waspc/data/Generator/templates/react-app/src/api/events.ts b/waspc/data/Generator/templates/sdk/api/events.ts
similarity index 58%
rename from waspc/data/Generator/templates/react-app/src/api/events.ts
rename to waspc/data/Generator/templates/sdk/api/events.ts
index 9a59b366d3..a72e48dda8 100644
--- a/waspc/data/Generator/templates/react-app/src/api/events.ts
+++ b/waspc/data/Generator/templates/sdk/api/events.ts
@@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
- 'authToken.set': void;
- 'authToken.clear': void;
+ 'sessionId.set': void;
+ 'sessionId.clear': void;
};
-// Used to allow API clients to register for auth token change events.
+// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter = mitt();
diff --git a/waspc/data/Generator/templates/react-app/src/api.ts b/waspc/data/Generator/templates/sdk/api/index.ts
similarity index 64%
rename from waspc/data/Generator/templates/react-app/src/api.ts
rename to waspc/data/Generator/templates/sdk/api/index.ts
index d7532f65c6..8b22dd7ebc 100644
--- a/waspc/data/Generator/templates/react-app/src/api.ts
+++ b/waspc/data/Generator/templates/sdk/api/index.ts
@@ -1,66 +1,67 @@
import axios, { type AxiosError } from 'axios'
-import config from './config'
-import { storage } from './storage'
-import { apiEventsEmitter } from './api/events'
+import config from 'wasp/core/config'
+import { storage } from 'wasp/core/storage'
+import { apiEventsEmitter } from 'wasp/api/events'
const api = axios.create({
baseURL: config.apiUrl,
})
-const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
+const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
-let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
+let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
-export function setAuthToken(token: string): void {
- authToken = token
- storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
- apiEventsEmitter.emit('authToken.set')
+export function setSessionId(sessionId: string): void {
+ waspAppAuthSessionId = sessionId
+ storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
+ apiEventsEmitter.emit('sessionId.set')
}
-export function getAuthToken(): string | undefined {
- return authToken
+export function getSessionId(): string | undefined {
+ return waspAppAuthSessionId
}
-export function clearAuthToken(): void {
- authToken = undefined
- storage.remove(WASP_APP_AUTH_TOKEN_NAME)
- apiEventsEmitter.emit('authToken.clear')
+export function clearSessionId(): void {
+ waspAppAuthSessionId = undefined
+ storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
+ apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
- authToken = undefined
+ waspAppAuthSessionId = undefined
storage.clear()
- apiEventsEmitter.emit('authToken.clear')
+ apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
- if (authToken) {
- request.headers['Authorization'] = `Bearer ${authToken}`
+ const sessionId = getSessionId()
+ if (sessionId) {
+ request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
- clearAuthToken()
+ clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
-// and will ensure they know about auth token changes.
+// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
- if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
+ if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
- authToken = event.newValue
- apiEventsEmitter.emit('authToken.set')
+ waspAppAuthSessionId = event.newValue
+ apiEventsEmitter.emit('sessionId.set')
} else {
- authToken = undefined
- apiEventsEmitter.emit('authToken.clear')
+ waspAppAuthSessionId = undefined
+ apiEventsEmitter.emit('sessionId.clear')
}
}
})
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/Auth.tsx b/waspc/data/Generator/templates/sdk/auth/forms/Auth.tsx
new file mode 100644
index 0000000000..92c58131f6
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/Auth.tsx
@@ -0,0 +1,85 @@
+import { useState, createContext } from 'react'
+import { createTheme } from '@stitches/react'
+import { styled } from 'wasp/core/stitches.config'
+
+import {
+ type State,
+ type CustomizationOptions,
+ type ErrorMessage,
+ type AdditionalSignupFields,
+} from './types'
+import { LoginSignupForm } from './internal/common/LoginSignupForm'
+import { MessageError, MessageSuccess } from './internal/Message'
+
+const logoStyle = {
+ height: '3rem'
+}
+
+const Container = styled('div', {
+ display: 'flex',
+ flexDirection: 'column',
+})
+
+const HeaderText = styled('h2', {
+ fontSize: '1.875rem',
+ fontWeight: '700',
+ marginTop: '1.5rem'
+})
+
+
+export const AuthContext = createContext({
+ isLoading: false,
+ setIsLoading: (isLoading: boolean) => {},
+ setErrorMessage: (errorMessage: ErrorMessage | null) => {},
+ setSuccessMessage: (successMessage: string | null) => {},
+})
+
+function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: {
+ state: State;
+} & CustomizationOptions & {
+ additionalSignupFields?: AdditionalSignupFields;
+}) {
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // TODO(matija): this is called on every render, is it a problem?
+ // If we do it in useEffect(), then there is a glitch between the default color and the
+ // user provided one.
+ const customTheme = createTheme(appearance ?? {})
+
+ const titles: Record = {
+ login: 'Log in to your account',
+ signup: 'Create a new account',
+ }
+ const title = titles[state]
+
+ const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal'
+
+ return (
+
+
+ {logo && (

)}
+
{title}
+
+
+ {errorMessage && (
+
+ {errorMessage.title}{errorMessage.description && ': '}{errorMessage.description}
+
+ )}
+ {successMessage && {successMessage}}
+
+ {(state === 'login' || state === 'signup') && (
+
+ )}
+
+
+ )
+}
+
+export default Auth;
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/Login.tsx b/waspc/data/Generator/templates/sdk/auth/forms/Login.tsx
new file mode 100644
index 0000000000..2ea532d9c5
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/Login.tsx
@@ -0,0 +1,17 @@
+import Auth from './Auth'
+import { type CustomizationOptions, State } from './types'
+
+export function LoginForm({
+ appearance,
+ logo,
+ socialLayout,
+}: CustomizationOptions) {
+ return (
+
+ )
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/Signup.tsx b/waspc/data/Generator/templates/sdk/auth/forms/Signup.tsx
new file mode 100644
index 0000000000..66ffab4503
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/Signup.tsx
@@ -0,0 +1,23 @@
+import Auth from './Auth'
+import {
+ type CustomizationOptions,
+ type AdditionalSignupFields,
+ State,
+} from './types'
+
+export function SignupForm({
+ appearance,
+ logo,
+ socialLayout,
+ additionalFields,
+}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) {
+ return (
+
+ )
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/Form.tsx b/waspc/data/Generator/templates/sdk/auth/forms/internal/Form.tsx
new file mode 100644
index 0000000000..781c75a0ae
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/Form.tsx
@@ -0,0 +1,95 @@
+import { styled } from 'wasp/core/stitches.config'
+
+export const Form = styled('form', {
+ marginTop: '1.5rem',
+})
+
+export const FormItemGroup = styled('div', {
+ '& + div': {
+ marginTop: '1.5rem',
+ },
+})
+
+export const FormLabel = styled('label', {
+ display: 'block',
+ fontSize: '$sm',
+ fontWeight: '500',
+ marginBottom: '0.5rem',
+})
+
+const commonInputStyles = {
+ display: 'block',
+ lineHeight: '1.5rem',
+ fontSize: '$sm',
+ borderWidth: '1px',
+ borderColor: '$gray600',
+ backgroundColor: '#f8f4ff',
+ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ '&:focus': {
+ borderWidth: '1px',
+ borderColor: '$gray700',
+ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ },
+ '&:disabled': {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ backgroundColor: '$gray400',
+ borderColor: '$gray400',
+ color: '$gray500',
+ },
+
+ borderRadius: '0.375rem',
+ width: '100%',
+
+ paddingTop: '0.375rem',
+ paddingBottom: '0.375rem',
+ paddingLeft: '0.75rem',
+ paddingRight: '0.75rem',
+ margin: 0,
+}
+
+export const FormInput = styled('input', commonInputStyles)
+
+export const FormTextarea = styled('textarea', commonInputStyles)
+
+export const FormError = styled('div', {
+ display: 'block',
+ fontSize: '$sm',
+ fontWeight: '500',
+ color: '$formErrorText',
+ marginTop: '0.5rem',
+})
+
+export const SubmitButton = styled('button', {
+ display: 'flex',
+ justifyContent: 'center',
+
+ width: '100%',
+ borderWidth: '1px',
+ borderColor: '$brand',
+ backgroundColor: '$brand',
+ color: '$submitButtonText',
+
+ padding: '0.5rem 0.75rem',
+ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+
+ fontWeight: '600',
+ fontSize: '$sm',
+ lineHeight: '1.25rem',
+ borderRadius: '0.375rem',
+
+ // TODO(matija): extract this into separate BaseButton component and then inherit it.
+ '&:hover': {
+ backgroundColor: '$brandAccent',
+ borderColor: '$brandAccent',
+ },
+ '&:disabled': {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ backgroundColor: '$gray400',
+ borderColor: '$gray400',
+ color: '$gray500',
+ },
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '100ms',
+})
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/Message.tsx b/waspc/data/Generator/templates/sdk/auth/forms/internal/Message.tsx
new file mode 100644
index 0000000000..7279ed2525
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/Message.tsx
@@ -0,0 +1,18 @@
+import { styled } from 'wasp/core/stitches.config'
+
+export const Message = styled('div', {
+ padding: '0.5rem 0.75rem',
+ borderRadius: '0.375rem',
+ marginTop: '1rem',
+ background: '$gray400',
+})
+
+export const MessageError = styled(Message, {
+ background: '$errorBackground',
+ color: '$errorText',
+})
+
+export const MessageSuccess = styled(Message, {
+ background: '$successBackground',
+ color: '$successText',
+})
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/sdk/auth/forms/internal/common/LoginSignupForm.tsx
new file mode 100644
index 0000000000..30665b4759
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/common/LoginSignupForm.tsx
@@ -0,0 +1,178 @@
+import { useContext } from 'react'
+import { useForm, UseFormReturn } from 'react-hook-form'
+import { styled } from 'wasp/core/stitches.config'
+import config from 'wasp/core/config'
+
+import { AuthContext } from '../../Auth'
+import {
+ Form,
+ FormInput,
+ FormItemGroup,
+ FormLabel,
+ FormError,
+ FormTextarea,
+ SubmitButton,
+} from '../Form'
+import type {
+ AdditionalSignupFields,
+ AdditionalSignupField,
+ AdditionalSignupFieldRenderFn,
+ FormState,
+} from '../../types'
+import { useHistory } from 'react-router-dom'
+import { useUsernameAndPassword } from '../usernameAndPassword/useUsernameAndPassword'
+
+
+export type LoginSignupFormFields = {
+ [key: string]: string;
+}
+
+export const LoginSignupForm = ({
+ state,
+ socialButtonsDirection = 'horizontal',
+ additionalSignupFields,
+}: {
+ state: 'login' | 'signup'
+ socialButtonsDirection?: 'horizontal' | 'vertical'
+ additionalSignupFields?: AdditionalSignupFields
+}) => {
+ const {
+ isLoading,
+ setErrorMessage,
+ setSuccessMessage,
+ setIsLoading,
+ } = useContext(AuthContext)
+ const isLogin = state === 'login'
+ const cta = isLogin ? 'Log in' : 'Sign up';
+ const history = useHistory();
+ const onErrorHandler = (error) => {
+ setErrorMessage({ title: error.message, description: error.data?.data?.message })
+ };
+ const hookForm = useForm()
+ const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
+ const { handleSubmit } = useUsernameAndPassword({
+ isLogin,
+ onError: onErrorHandler,
+ onSuccess() {
+ history.push('/')
+ },
+ });
+ async function onSubmit (data) {
+ setIsLoading(true);
+ setErrorMessage(null);
+ setSuccessMessage(null);
+ try {
+ await handleSubmit(data);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (<>
+
+ >)
+}
+
+function AdditionalFormFields({
+ hookForm,
+ formState: { isLoading },
+ additionalSignupFields,
+}: {
+ hookForm: UseFormReturn;
+ formState: FormState;
+ additionalSignupFields: AdditionalSignupFields;
+}) {
+ const {
+ register,
+ formState: { errors },
+ } = hookForm;
+
+ function renderField>(
+ field: AdditionalSignupField,
+ // Ideally we would use ComponentType here, but it doesn't work with react-hook-form
+ Component: any,
+ props?: React.ComponentProps
+ ) {
+ return (
+
+ {field.label}
+
+ {errors[field.name] && (
+ {errors[field.name].message}
+ )}
+
+ );
+ }
+
+ if (areAdditionalFieldsRenderFn(additionalSignupFields)) {
+ return additionalSignupFields(hookForm, { isLoading })
+ }
+
+ return (
+ additionalSignupFields &&
+ additionalSignupFields.map((field) => {
+ if (isFieldRenderFn(field)) {
+ return field(hookForm, { isLoading })
+ }
+ switch (field.type) {
+ case 'input':
+ return renderField(field, FormInput, {
+ type: 'text',
+ })
+ case 'textarea':
+ return renderField(field, FormTextarea)
+ default:
+ throw new Error(
+ `Unsupported additional signup field type: ${field.type}`
+ )
+ }
+ })
+ )
+}
+
+function isFieldRenderFn(
+ additionalSignupField: AdditionalSignupField | AdditionalSignupFieldRenderFn
+): additionalSignupField is AdditionalSignupFieldRenderFn {
+ return typeof additionalSignupField === 'function'
+}
+
+function areAdditionalFieldsRenderFn(
+ additionalSignupFields: AdditionalSignupFields
+): additionalSignupFields is AdditionalSignupFieldRenderFn {
+ return typeof additionalSignupFields === 'function'
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts b/waspc/data/Generator/templates/sdk/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts
new file mode 100644
index 0000000000..247c1faeb4
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts
@@ -0,0 +1,29 @@
+import signup from '../../../signup'
+import login from '../../../login'
+
+export function useUsernameAndPassword({
+ onError,
+ onSuccess,
+ isLogin,
+}: {
+ onError: (error: Error) => void
+ onSuccess: () => void
+ isLogin: boolean
+}) {
+ async function handleSubmit(data) {
+ try {
+ if (!isLogin) {
+ await signup(data)
+ }
+ await login(data.username, data.password)
+
+ onSuccess()
+ } catch (err: unknown) {
+ onError(err as Error)
+ }
+ }
+
+ return {
+ handleSubmit,
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/forms/types.ts b/waspc/data/Generator/templates/sdk/auth/forms/types.ts
new file mode 100644
index 0000000000..14d61ad51e
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/forms/types.ts
@@ -0,0 +1,39 @@
+import { createTheme } from '@stitches/react'
+import { UseFormReturn, RegisterOptions } from 'react-hook-form'
+import type { LoginSignupFormFields } from './internal/common/LoginSignupForm'
+
+export enum State {
+ Login = 'login',
+ Signup = 'signup',
+}
+
+export type CustomizationOptions = {
+ logo?: string
+ socialLayout?: 'horizontal' | 'vertical'
+ appearance?: Parameters[0]
+}
+
+export type ErrorMessage = {
+ title: string
+ description?: string
+}
+
+export type FormState = {
+ isLoading: boolean
+}
+
+export type AdditionalSignupFieldRenderFn = (
+ hookForm: UseFormReturn,
+ formState: FormState
+) => React.ReactNode
+
+export type AdditionalSignupField = {
+ name: string
+ label: string
+ type: 'input' | 'textarea'
+ validations?: RegisterOptions
+}
+
+export type AdditionalSignupFields =
+ | (AdditionalSignupField | AdditionalSignupFieldRenderFn)[]
+ | AdditionalSignupFieldRenderFn
diff --git a/waspc/data/Generator/templates/sdk/auth/helpers/user.ts b/waspc/data/Generator/templates/sdk/auth/helpers/user.ts
new file mode 100644
index 0000000000..498f2588a8
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/helpers/user.ts
@@ -0,0 +1,14 @@
+import { setSessionId } from 'wasp/api'
+import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
+
+export async function initSession(sessionId: string): Promise {
+ setSessionId(sessionId)
+ // We need to invalidate queries after login in order to get the correct user
+ // data in the React components (using `useAuth`).
+ // Redirects after login won't work properly without this.
+
+ // TODO(filip): We are currently removing all the queries, but we should
+ // remove only non-public, user-dependent queries - public queries are
+ // expected not to change in respect to the currently logged in user.
+ await invalidateAndRemoveQueries()
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/jwt.ts b/waspc/data/Generator/templates/sdk/auth/jwt.ts
new file mode 100644
index 0000000000..b244990158
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/jwt.ts
@@ -0,0 +1,12 @@
+import jwt from 'jsonwebtoken'
+import util from 'util'
+
+import config from 'wasp/server/config'
+
+const jwtSign = util.promisify(jwt.sign)
+const jwtVerify = util.promisify(jwt.verify)
+
+const JWT_SECRET = config.auth.jwtSecret
+
+export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
+export const verify = (token) => jwtVerify(token, JWT_SECRET)
diff --git a/waspc/data/Generator/templates/sdk/auth/login.ts b/waspc/data/Generator/templates/sdk/auth/login.ts
new file mode 100644
index 0000000000..2b4ec4b9fe
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/login.ts
@@ -0,0 +1,13 @@
+import api, { handleApiError } from 'wasp/api'
+import { initSession } from './helpers/user'
+
+export default async function login(username: string, password: string): Promise {
+ try {
+ const args = { username, password }
+ const response = await api.post('/auth/username/login', args)
+
+ await initSession(response.data.sessionId)
+ } catch (error) {
+ handleApiError(error)
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/logout.ts b/waspc/data/Generator/templates/sdk/auth/logout.ts
new file mode 100644
index 0000000000..cc41b6989c
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/logout.ts
@@ -0,0 +1,17 @@
+import api, { removeLocalUserData } from 'wasp/api'
+import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
+
+export default async function logout(): Promise {
+ try {
+ await api.post('/auth/logout')
+ } finally {
+ // Even if the logout request fails, we still want to remove the local user data
+ // in case the logout failed because of a network error and the user walked away
+ // from the computer.
+ removeLocalUserData()
+
+ // TODO(filip): We are currently invalidating and removing all the queries, but
+ // we should remove only the non-public, user-dependent ones.
+ await invalidateAndRemoveQueries()
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/lucia.ts b/waspc/data/Generator/templates/sdk/auth/lucia.ts
new file mode 100644
index 0000000000..168fdf4a4e
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/lucia.ts
@@ -0,0 +1,54 @@
+import { Lucia } from "lucia";
+import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
+import prisma from '../server/dbClient.js'
+import { type User } from "../entities/index.js"
+
+const prismaAdapter = new PrismaAdapter(
+ // Using `as any` here since Lucia's model types are not compatible with Prisma 4
+ // model types. This is a temporary workaround until we migrate to Prisma 5.
+ // This **works** in runtime, but Typescript complains about it.
+ prisma.session as any,
+ prisma.auth as any
+);
+
+/**
+ * We are using Lucia for session management.
+ *
+ * Some details:
+ * 1. We are using the Prisma adapter for Lucia.
+ * 2. We are not using cookies for session management. Instead, we are using
+ * the Authorization header to send the session token.
+ * 3. Our `Session` entity is connected to the `Auth` entity.
+ * 4. We are exposing the `userId` field from the `Auth` entity to
+ * make fetching the User easier.
+ */
+export const auth = new Lucia<{}, {
+ userId: User['id']
+}>(prismaAdapter, {
+ // Since we are not using cookies, we don't need to set any cookie options.
+ // But in the future, if we decide to use cookies, we can set them here.
+
+ // sessionCookie: {
+ // name: "session",
+ // expires: true,
+ // attributes: {
+ // secure: !config.isDevelopment,
+ // sameSite: "lax",
+ // },
+ // },
+ getUserAttributes({ userId }) {
+ return {
+ userId,
+ };
+ },
+});
+
+declare module "lucia" {
+ interface Register {
+ Lucia: typeof auth;
+ DatabaseSessionAttributes: {};
+ DatabaseUserAttributes: {
+ userId: User['id']
+ };
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/pages/createAuthRequiredPage.jsx b/waspc/data/Generator/templates/sdk/auth/pages/createAuthRequiredPage.jsx
new file mode 100644
index 0000000000..621ef393d9
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/pages/createAuthRequiredPage.jsx
@@ -0,0 +1,30 @@
+import React from 'react'
+
+import { Redirect } from 'react-router-dom'
+import useAuth from '../useAuth'
+
+
+const createAuthRequiredPage = (Page) => {
+ return (props) => {
+ const { data: user, isError, isSuccess, isLoading } = useAuth()
+
+ if (isSuccess) {
+ if (user) {
+ return (
+
+ )
+ } else {
+ return
+ }
+ } else if (isLoading) {
+ return Loading...
+ } else if (isError) {
+ return An error ocurred. Please refresh the page.
+ } else {
+ return An unknown error ocurred. Please refresh the page.
+ }
+ }
+}
+
+export default createAuthRequiredPage
+
diff --git a/waspc/data/Generator/templates/sdk/auth/password.ts b/waspc/data/Generator/templates/sdk/auth/password.ts
new file mode 100644
index 0000000000..a359892b5e
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/password.ts
@@ -0,0 +1,15 @@
+import SecurePassword from 'secure-password'
+
+const SP = new SecurePassword()
+
+export const hashPassword = async (password: string): Promise => {
+ const hashedPwdBuffer = await SP.hash(Buffer.from(password))
+ return hashedPwdBuffer.toString("base64")
+}
+
+export const verifyPassword = async (hashedPassword: string, password: string): Promise => {
+ const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
+ if (result !== SecurePassword.VALID) {
+ throw new Error('Invalid password.')
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/providers/types.ts b/waspc/data/Generator/templates/sdk/auth/providers/types.ts
new file mode 100644
index 0000000000..76e1114850
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/providers/types.ts
@@ -0,0 +1,40 @@
+import type { Router, Request } from 'express'
+import type { Prisma } from '@prisma/client'
+import type { Expand } from 'wasp/universal/types'
+import type { ProviderName } from '../utils'
+
+type UserEntityCreateInput = Prisma.UserCreateInput
+
+export type ProviderConfig = {
+ // Unique provider identifier, used as part of URL paths
+ id: ProviderName;
+ displayName: string;
+ // Each provider config can have an init method which is ran on setup time
+ // e.g. for oAuth providers this is the time when the Passport strategy is registered.
+ init?(provider: ProviderConfig): Promise;
+ // Every provider must have a setupRouter method which returns the Express router.
+ // In this function we are flexibile to do what ever is necessary to make the provider work.
+ createRouter(provider: ProviderConfig, initData: InitData): Router;
+};
+
+export type InitData = {
+ [key: string]: any;
+}
+
+export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
+
+export type PossibleUserFields = Expand>
+
+export type UserSignupFields = {
+ [key in keyof PossibleUserFields]: FieldGetter<
+ PossibleUserFields[key]
+ >
+}
+
+type FieldGetter = (
+ data: { [key: string]: unknown }
+) => Promise | T | undefined
+
+export function defineUserSignupFields(fields: UserSignupFields) {
+ return fields
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/session.ts b/waspc/data/Generator/templates/sdk/auth/session.ts
new file mode 100644
index 0000000000..0d1590674c
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/session.ts
@@ -0,0 +1,107 @@
+import { Request as ExpressRequest } from "express";
+
+import { type User } from "wasp/entities"
+import { type SanitizedUser } from 'wasp/server/_types/index.js'
+
+import { auth } from "./lucia.js";
+import type { Session } from "lucia";
+import {
+ throwInvalidCredentialsError,
+ deserializeAndSanitizeProviderData,
+} from "./utils.js";
+
+import prisma from 'wasp/server/dbClient'
+
+// Creates a new session for the `authId` in the database
+export async function createSession(authId: string): Promise {
+ return auth.createSession(authId, {});
+}
+
+export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{
+ user: SanitizedUser | null,
+ session: Session | null,
+}> {
+ const authorizationHeader = req.headers["authorization"];
+
+ if (typeof authorizationHeader !== "string") {
+ return {
+ user: null,
+ session: null,
+ };
+ }
+
+ const sessionId = auth.readBearerToken(authorizationHeader);
+ if (!sessionId) {
+ return {
+ user: null,
+ session: null,
+ };
+ }
+
+ return getSessionAndUserFromSessionId(sessionId);
+}
+
+export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{
+ user: SanitizedUser | null,
+ session: Session | null,
+}> {
+ const { session, user: authEntity } = await auth.validateSession(sessionId);
+
+ if (!session || !authEntity) {
+ return {
+ user: null,
+ session: null,
+ };
+ }
+
+ return {
+ session,
+ user: await getUser(authEntity.userId)
+ }
+}
+
+async function getUser(userId: User['id']): Promise {
+ const user = await prisma.user
+ .findUnique({
+ where: { id: userId },
+ include: {
+ auth: {
+ include: {
+ identities: true
+ }
+ }
+ }
+ })
+
+ if (!user) {
+ throwInvalidCredentialsError()
+ }
+
+ // TODO: This logic must match the type in _types/index.ts (if we remove the
+ // password field from the object here, we must to do the same there).
+ // Ideally, these two things would live in the same place:
+ // https://github.com/wasp-lang/wasp/issues/965
+ const deserializedIdentities = user.auth.identities.map((identity) => {
+ const deserializedProviderData = deserializeAndSanitizeProviderData(
+ identity.providerData,
+ {
+ shouldRemovePasswordField: true,
+ }
+ )
+ return {
+ ...identity,
+ providerData: deserializedProviderData,
+ }
+ })
+ return {
+ ...user,
+ auth: {
+ ...user.auth,
+ identities: deserializedIdentities,
+ },
+ }
+}
+
+export function invalidateSession(sessionId: string): Promise {
+ return auth.invalidateSession(sessionId);
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/signup.ts b/waspc/data/Generator/templates/sdk/auth/signup.ts
new file mode 100644
index 0000000000..bde50c5ebd
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/signup.ts
@@ -0,0 +1,9 @@
+import api, { handleApiError } from 'wasp/api'
+
+export default async function signup(userFields: { username: string; password: string }): Promise {
+ try {
+ await api.post('/auth/username/signup', userFields)
+ } catch (error) {
+ handleApiError(error)
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/types.ts b/waspc/data/Generator/templates/sdk/auth/types.ts
new file mode 100644
index 0000000000..f9f079a57a
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/types.ts
@@ -0,0 +1,2 @@
+// todo(filip): turn into a proper import/path
+export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/'
diff --git a/waspc/data/Generator/templates/sdk/auth/useAuth.ts b/waspc/data/Generator/templates/sdk/auth/useAuth.ts
new file mode 100644
index 0000000000..29b95f62a0
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/useAuth.ts
@@ -0,0 +1,38 @@
+import { deserialize as superjsonDeserialize } from 'superjson'
+import { useQuery } from 'wasp/rpc'
+import api, { handleApiError } from 'wasp/api'
+import { HttpMethod } from 'wasp/types'
+import type { User } from './types'
+import { addMetadataToQuery } from 'wasp/rpc/queries'
+
+export const getMe = createUserGetter()
+
+export default function useAuth(queryFnArgs?: unknown, config?: any) {
+ return useQuery(getMe, queryFnArgs, config)
+}
+
+function createUserGetter() {
+ const getMeRelativePath = 'auth/me'
+ const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
+ async function getMe(): Promise {
+ try {
+ const response = await api.get(getMeRoute.path)
+
+ return superjsonDeserialize(response.data)
+ } catch (error) {
+ if (error.response?.status === 401) {
+ return null
+ } else {
+ handleApiError(error)
+ }
+ }
+ }
+
+ addMetadataToQuery(getMe, {
+ relativeQueryPath: getMeRelativePath,
+ queryRoute: getMeRoute,
+ entitiesUsed: ['User'],
+ })
+
+ return getMe
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/user.ts b/waspc/data/Generator/templates/sdk/auth/user.ts
new file mode 100644
index 0000000000..aa0da24824
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/user.ts
@@ -0,0 +1,27 @@
+// We decided not to deduplicate these helper functions in the server and the client.
+// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
+// If you are changing the logic here, make sure to change it there as well.
+
+import type { User, ProviderName, DeserializedAuthIdentity } from './types'
+
+export function getEmail(user: User): string | null {
+ return findUserIdentity(user, "email")?.providerUserId ?? null;
+}
+
+export function getUsername(user: User): string | null {
+ return findUserIdentity(user, "username")?.providerUserId ?? null;
+}
+
+export function getFirstProviderUserId(user?: User): string | null {
+ if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) {
+ return null;
+ }
+
+ return user.auth.identities[0].providerUserId ?? null;
+}
+
+export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
+ return user.auth.identities.find(
+ (identity) => identity.providerName === providerName
+ );
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/utils.ts b/waspc/data/Generator/templates/sdk/auth/utils.ts
new file mode 100644
index 0000000000..15f8531261
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/utils.ts
@@ -0,0 +1,302 @@
+import { hashPassword } from './password.js'
+import { verify } from './jwt.js'
+import AuthError from 'wasp/core/AuthError'
+import HttpError from 'wasp/core/HttpError'
+import prisma from 'wasp/server/dbClient'
+import { sleep } from 'wasp/server/utils'
+import {
+ type User,
+ type Auth,
+ type AuthIdentity,
+} from 'wasp/entities'
+import { Prisma } from '@prisma/client';
+
+import { throwValidationError } from './validation.js'
+
+import { type UserSignupFields, type PossibleUserFields } from './providers/types.js'
+
+export type EmailProviderData = {
+ hashedPassword: string;
+ isEmailVerified: boolean;
+ emailVerificationSentAt: string | null;
+ passwordResetSentAt: string | null;
+}
+
+export type UsernameProviderData = {
+ hashedPassword: string;
+}
+
+export type OAuthProviderData = {}
+
+/**
+ * This type is used for type-level programming e.g. to enumerate
+ * all possible provider data types.
+ *
+ * The keys of this type are the names of the providers and the values
+ * are the types of the provider data.
+ */
+export type PossibleProviderData = {
+ email: EmailProviderData;
+ username: UsernameProviderData;
+ google: OAuthProviderData;
+ github: OAuthProviderData;
+}
+
+export type ProviderName = keyof PossibleProviderData
+
+export const contextWithUserEntity = {
+ entities: {
+ User: prisma.user
+ }
+}
+
+export const authConfig = {
+ failureRedirectPath: "/login",
+ successRedirectPath: "/",
+}
+
+/**
+ * ProviderId uniquely identifies an auth identity e.g.
+ * "email" provider with user id "test@test.com" or
+ * "google" provider with user id "1234567890".
+ *
+ * We use this type to avoid passing the providerName and providerUserId
+ * separately. Also, we can normalize the providerUserId to make sure it's
+ * consistent across different DB operations.
+ */
+export type ProviderId = {
+ providerName: ProviderName;
+ providerUserId: string;
+}
+
+export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId {
+ return {
+ providerName,
+ providerUserId: providerUserId.toLowerCase(),
+ }
+}
+
+export async function findAuthIdentity(providerId: ProviderId): Promise {
+ return prisma.authIdentity.findUnique({
+ where: {
+ providerName_providerUserId: providerId,
+ }
+ });
+}
+
+/**
+ * Updates the provider data for the given auth identity.
+ *
+ * This function performs data sanitization and serialization.
+ * Sanitization is done by hashing the password, so this function
+ * expects the password received in the `providerDataUpdates`
+ * **not to be hashed**.
+ */
+export async function updateAuthIdentityProviderData(
+ providerId: ProviderId,
+ existingProviderData: PossibleProviderData[PN],
+ providerDataUpdates: Partial,
+): Promise {
+ // We are doing the sanitization here only on updates to avoid
+ // hashing the password multiple times.
+ const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates);
+ const newProviderData = {
+ ...existingProviderData,
+ ...sanitizedProviderDataUpdates,
+ }
+ const serializedProviderData = await serializeProviderData(newProviderData);
+ return prisma.authIdentity.update({
+ where: {
+ providerName_providerUserId: providerId,
+ },
+ data: { providerData: serializedProviderData },
+ });
+}
+
+type FindAuthWithUserResult = Auth & {
+ user: User
+}
+
+export async function findAuthWithUserBy(
+ where: Prisma.AuthWhereInput
+): Promise {
+ return prisma.auth.findFirst({ where, include: { user: true }});
+}
+
+export async function createUser(
+ providerId: ProviderId,
+ serializedProviderData?: string,
+ userFields?: PossibleUserFields,
+): Promise {
+ return prisma.user.create({
+ data: {
+ // Using any here to prevent type errors when userFields are not
+ // defined. We want Prisma to throw an error in that case.
+ ...(userFields ?? {} as any),
+ auth: {
+ create: {
+ identities: {
+ create: {
+ providerName: providerId.providerName,
+ providerUserId: providerId.providerUserId,
+ providerData: serializedProviderData,
+ },
+ },
+ }
+ },
+ },
+ // We need to include the Auth entity here because we need `authId`
+ // to be able to create a session.
+ include: {
+ auth: true,
+ },
+ })
+}
+
+export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> {
+ return prisma.user.deleteMany({ where: { auth: {
+ id: authId,
+ } } })
+}
+
+export async function verifyToken(token: string): Promise {
+ return verify(token);
+}
+
+// If an user exists, we don't want to leak information
+// about it. Pretending that we're doing some work
+// will make it harder for an attacker to determine
+// if a user exists or not.
+// NOTE: Attacker measuring time to response can still determine
+// if a user exists or not. We'll be able to avoid it when
+// we implement e-mail sending via jobs.
+export async function doFakeWork(): Promise {
+ const timeToWork = Math.floor(Math.random() * 1000) + 1000;
+ return sleep(timeToWork);
+}
+
+export function rethrowPossibleAuthError(e: unknown): void {
+ if (e instanceof AuthError) {
+ throwValidationError((e as any).message);
+ }
+
+ // Prisma code P2002 is for unique constraint violations.
+ if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
+ throw new HttpError(422, 'Save failed', {
+ message: `user with the same identity already exists`,
+ })
+ }
+
+ if (e instanceof Prisma.PrismaClientValidationError) {
+ // NOTE: Logging the error since this usually means that there are
+ // required fields missing in the request, we want the developer
+ // to know about it.
+ console.error(e)
+ throw new HttpError(422, 'Save failed', {
+ message: 'there was a database error'
+ })
+ }
+
+ // Prisma code P2021 is for missing table errors.
+ if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') {
+ // NOTE: Logging the error since this usually means that the database
+ // migrations weren't run, we want the developer to know about it.
+ console.error(e)
+ console.info('🐝 This error can happen if you did\'t run the database migrations.')
+ throw new HttpError(500, 'Save failed', {
+ message: `there was a database error`,
+ })
+ }
+
+ // Prisma code P2003 is for foreign key constraint failure
+ if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') {
+ console.error(e)
+ console.info(`🐝 This error can happen if you have some relation on your User entity
+ but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull".
+ Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`)
+ throw new HttpError(500, 'Save failed', {
+ message: `there was a database error`,
+ })
+ }
+
+ throw e
+}
+
+export async function validateAndGetUserFields(
+ data: {
+ [key: string]: unknown
+ },
+ userSignupFields?: UserSignupFields,
+): Promise> {
+ const {
+ password: _password,
+ ...sanitizedData
+ } = data;
+ const result: Record = {};
+
+ if (!userSignupFields) {
+ return result;
+ }
+
+ for (const [field, getFieldValue] of Object.entries(userSignupFields)) {
+ try {
+ const value = await getFieldValue(sanitizedData)
+ result[field] = value
+ } catch (e) {
+ throwValidationError(e.message)
+ }
+ }
+ return result;
+}
+
+export function deserializeAndSanitizeProviderData(
+ providerData: string,
+ { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {},
+): PossibleProviderData[PN] {
+ // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON.
+ let data = JSON.parse(providerData) as PossibleProviderData[PN];
+
+ if (providerDataHasPasswordField(data) && shouldRemovePasswordField) {
+ delete data.hashedPassword;
+ }
+
+ return data;
+}
+
+export async function sanitizeAndSerializeProviderData(
+ providerData: PossibleProviderData[PN],
+): Promise {
+ return serializeProviderData(
+ await sanitizeProviderData(providerData)
+ );
+}
+
+function serializeProviderData(providerData: PossibleProviderData[PN]): string {
+ return JSON.stringify(providerData);
+}
+
+async function sanitizeProviderData(
+ providerData: PossibleProviderData[PN],
+): Promise {
+ const data = {
+ ...providerData,
+ };
+ if (providerDataHasPasswordField(data)) {
+ data.hashedPassword = await hashPassword(data.hashedPassword);
+ }
+
+ return data;
+}
+
+
+function providerDataHasPasswordField(
+ providerData: PossibleProviderData[keyof PossibleProviderData],
+): providerData is { hashedPassword: string } {
+ return 'hashedPassword' in providerData;
+}
+
+export function throwInvalidCredentialsError(message?: string): void {
+ throw new HttpError(401, 'Invalid credentials', { message })
+}
diff --git a/waspc/data/Generator/templates/sdk/auth/validation.ts b/waspc/data/Generator/templates/sdk/auth/validation.ts
new file mode 100644
index 0000000000..73bac13e21
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/auth/validation.ts
@@ -0,0 +1,77 @@
+import HttpError from 'wasp/core/HttpError';
+
+export const PASSWORD_FIELD = 'password';
+const USERNAME_FIELD = 'username';
+const EMAIL_FIELD = 'email';
+const TOKEN_FIELD = 'token';
+
+export function ensureValidEmail(args: unknown): void {
+ validate(args, [
+ { validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email },
+ { validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) },
+ ]);
+}
+
+export function ensureValidUsername(args: unknown): void {
+ validate(args, [
+ { validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username }
+ ]);
+}
+
+export function ensurePasswordIsPresent(args: unknown): void {
+ validate(args, [
+ { validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password },
+ ]);
+}
+
+export function ensureValidPassword(args: unknown): void {
+ validate(args, [
+ { validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => isMinLength(password, 8) },
+ { validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => containsNumber(password) },
+ ]);
+}
+
+export function ensureTokenIsPresent(args: unknown): void {
+ validate(args, [
+ { validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token },
+ ]);
+}
+
+export function throwValidationError(message: string): void {
+ throw new HttpError(422, 'Validation failed', { message })
+}
+
+function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void {
+ for (const { validates, message, validator } of validators) {
+ if (!validator(args[validates])) {
+ throwValidationError(message);
+ }
+ }
+}
+
+// NOTE(miho): it would be good to replace our custom validations with e.g. Zod
+
+const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/
+function isValidEmail(input: unknown): boolean {
+ if (typeof input !== 'string') {
+ return false
+ }
+
+ return input.match(validEmailRegex) !== null
+}
+
+function isMinLength(input: unknown, minLength: number): boolean {
+ if (typeof input !== 'string') {
+ return false
+ }
+
+ return input.length >= minLength
+}
+
+function containsNumber(input: unknown): boolean {
+ if (typeof input !== 'string') {
+ return false
+ }
+
+ return /\d/.test(input)
+}
diff --git a/waspc/data/Generator/templates/server/src/core/AuthError.js b/waspc/data/Generator/templates/sdk/core/AuthError.js
similarity index 100%
rename from waspc/data/Generator/templates/server/src/core/AuthError.js
rename to waspc/data/Generator/templates/sdk/core/AuthError.js
diff --git a/waspc/data/Generator/templates/server/src/core/HttpError.js b/waspc/data/Generator/templates/sdk/core/HttpError.js
similarity index 100%
rename from waspc/data/Generator/templates/server/src/core/HttpError.js
rename to waspc/data/Generator/templates/sdk/core/HttpError.js
diff --git a/waspc/data/Generator/templates/sdk/core/auth.js b/waspc/data/Generator/templates/sdk/core/auth.js
new file mode 100644
index 0000000000..2408af794c
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/core/auth.js
@@ -0,0 +1,38 @@
+import { handleRejection } from 'wasp/server/utils'
+import { getSessionAndUserFromBearerToken } from 'wasp/auth/session'
+import { throwInvalidCredentialsError } from 'wasp/auth/utils'
+
+/**
+ * Auth middleware
+ *
+ * If the request includes an `Authorization` header it will try to authenticate the request,
+ * otherwise it will let the request through.
+ *
+ * - If authentication succeeds it sets `req.sessionId` and `req.user`
+ * - `req.user` is the user that made the request and it's used in
+ * all Wasp features that need to know the user that made the request.
+ * - `req.sessionId` is the ID of the session that authenticated the request.
+ * - If the request is not authenticated, it throws an error.
+ */
+const auth = handleRejection(async (req, res, next) => {
+ const authHeader = req.get('Authorization')
+ if (!authHeader) {
+ // NOTE(matija): for now we let tokenless requests through and make it operation's
+ // responsibility to verify whether the request is authenticated or not. In the future
+ // we will develop our own system at Wasp-level for that.
+ return next()
+ }
+
+ const { session, user } = await getSessionAndUserFromBearerToken(req);
+
+ if (!session || !user) {
+ throwInvalidCredentialsError()
+ }
+
+ req.sessionId = session.id
+ req.user = user
+
+ next()
+})
+
+export default auth
diff --git a/waspc/data/Generator/templates/sdk/core/config.js b/waspc/data/Generator/templates/sdk/core/config.js
new file mode 100644
index 0000000000..e9234e6f2a
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/core/config.js
@@ -0,0 +1,9 @@
+import { stripTrailingSlash } from 'wasp/universal/url'
+
+const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || 'http://localhost:3001';
+
+const config = {
+ apiUrl,
+}
+
+export default config
diff --git a/waspc/data/Generator/templates/sdk/core/stitches.config.js b/waspc/data/Generator/templates/sdk/core/stitches.config.js
new file mode 100644
index 0000000000..c1d600a3f6
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/core/stitches.config.js
@@ -0,0 +1,33 @@
+import { createStitches } from '@stitches/react'
+
+export const {
+ styled,
+ css
+} = createStitches({
+ theme: {
+ colors: {
+ waspYellow: '#ffcc00',
+ gray700: '#a1a5ab',
+ gray600: '#d1d5db',
+ gray500: 'gainsboro',
+ gray400: '#f0f0f0',
+ red: '#FED7D7',
+ darkRed: '#fa3838',
+ green: '#C6F6D5',
+
+ brand: '$waspYellow',
+ brandAccent: '#ffdb46',
+ errorBackground: '$red',
+ errorText: '#2D3748',
+ successBackground: '$green',
+ successText: '#2D3748',
+
+ submitButtonText: 'black',
+
+ formErrorText: '$darkRed',
+ },
+ fontSizes: {
+ sm: '0.875rem'
+ }
+ }
+})
diff --git a/waspc/data/Generator/templates/sdk/core/storage.ts b/waspc/data/Generator/templates/sdk/core/storage.ts
new file mode 100644
index 0000000000..0321acea8b
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/core/storage.ts
@@ -0,0 +1,50 @@
+export type DataStore = {
+ getPrefixedKey(key: string): string
+ set(key: string, value: unknown): void
+ get(key: string): unknown
+ remove(key: string): void
+ clear(): void
+}
+
+function createLocalStorageDataStore(prefix: string): DataStore {
+ function getPrefixedKey(key: string): string {
+ return `${prefix}:${key}`
+ }
+
+ return {
+ getPrefixedKey,
+ set(key, value) {
+ ensureLocalStorageIsAvailable()
+ localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
+ },
+ get(key) {
+ ensureLocalStorageIsAvailable()
+ const value = localStorage.getItem(getPrefixedKey(key))
+ try {
+ return value ? JSON.parse(value) : undefined
+ } catch (e: any) {
+ return undefined
+ }
+ },
+ remove(key) {
+ ensureLocalStorageIsAvailable()
+ localStorage.removeItem(getPrefixedKey(key))
+ },
+ clear() {
+ ensureLocalStorageIsAvailable()
+ Object.keys(localStorage).forEach((key) => {
+ if (key.startsWith(prefix)) {
+ localStorage.removeItem(key)
+ }
+ })
+ },
+ }
+}
+
+export const storage = createLocalStorageDataStore('wasp')
+
+function ensureLocalStorageIsAvailable(): void {
+ if (!window.localStorage) {
+ throw new Error('Local storage is not available.')
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/dependencies.txt b/waspc/data/Generator/templates/sdk/dependencies.txt
new file mode 100644
index 0000000000..56e1643232
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/dependencies.txt
@@ -0,0 +1,132 @@
+Dependencies:
+
+("@prisma/client", show prismaVersion), // sdk
+("@tanstack/react-query", "^4.29.0"), // sdk
+("axios", "^1.4.0"), // sdk
+("cookie-parser", "~1.4.6"), //
+("cors", "^2.8.5"), //
+("dotenv", "16.0.2"), //
+("express", "~4.18.1"), // sdk (for types)
+("helmet", "^6.0.0"), //
+("jsonwebtoken", "^8.5.1"), // sdk
+("lodash.merge", "^4.6.2"), //
+("mitt", "3.0.0"), // sdk
+("morgan", "~1.10.0"), //
+("patch-package", "^6.4.7"), //
+("rate-limiter-flexible", "^2.4.1"), //
+("react", "^18.2.0"), // sdk
+("react-dom", "^18.2.0"), //
+("react-hook-form", "^7.45.4") //
+("react-router-dom", "^5.3.3"), // sdk
+("secure-password", "^4.0.0"), // sdk
+("superjson", "^1.12.2"), // sdk
+("uuid", "^9.0.0"), //
+
+Dev dependencies:
+("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"),
+("@tsconfig/vite-react", "^2.0.0")
+("@types/cors", "^2.8.5")
+("@types/express", "^4.17.13"),
+("@types/express-serve-static-core", "^4.17.13"),
+("@types/node", "^18.11.9"),
+("@types/react", "^18.0.37"),
+("@types/react-dom", "^18.0.11"),
+("@types/react-router-dom", "^5.3.3"),
+("@types/uuid", "^9.0.0"),
+("@vitejs/plugin-react-swc", "^3.0.0"),
+("dotenv", "^16.0.3"), // duplicate
+("nodemon", "^2.0.19"), //
+("prisma", show prismaVersion), //
+("standard", "^17.0.0"), //
+("typescript", "^5.1.0"), //
+("vite", "^4.3.9"), //
+
+Their package.json:
+("react", "^18.2.0"),
+("typescript", "^5.1.0")
+
+
+Server
+
+("cookie-parser", "~1.4.6"),
+- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts
+
+("cors", "^2.8.5"),
+- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts
+
+("express", "~4.18.1"),
+- Generator/templates/server/src/auth/providers/config/local.ts
+- Generator/templates/server/src/auth/providers/config/email.ts
+- Generator/templates/server/src/routes/crud/index.ts
+- Generator/templates/server/src/routes/crud/_crud.ts
+- Generator/templates/server/src/routes/operations/index.js
+- Generator/templates/server/src/routes/index.js
+- Generator/templates/server/src/auth/providers/index.ts
+- Generator/templates/server/src/auth/providers/oauth/createRouter.ts
+- Generator/templates/server/src/routes/apis/index.ts
+- Generator/templates/server/src/auth/providers/types.ts
+- Generator/templates/server/src/types/index.ts
+- Generator/templates/server/src/middleware/globalMiddleware.ts
+- Generator/templates/server/src/app.js
+- Generator/templates/server/src/auth/providers/email/signup.ts
+- Generator/templates/server/src/routes/auth/index.js
+- Generator/templates/server/src/auth/providers/email/login.ts
+- Generator/templates/server/src/auth/providers/email/resetPassword.ts
+- Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts
+- Generator/templates/server/src/auth/providers/email/verifyEmail.ts
+- Generator/templates/server/src/_types/index.ts
+- Generator/templates/server/src/apis/types.ts
+
+("morgan", "~1.10.0"),
+- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts
+
+("@prisma/client", show prismaVersion),
+- [SDK] Generator/templates/react-app/src/entities/index.ts
+- [SDK] Generator/templates/server/src/dbClient.ts
+- [Framework] Generator/templates/server/src/utils.js
+- Generator/templates/server/src/auth/utils.ts
+- Generator/templates/server/src/entities/index.ts
+- Generator/templates/server/src/auth/providers/oauth/types.ts
+- Generator/templates/server/src/crud/_operations.ts
+- Generator/templates/server/src/dbSeed/types.ts
+
+
+("jsonwebtoken", "^8.5.1"),
+-- NOTE: secure-password has a package.json override for sodium-native.
+("secure-password", "^4.0.0"),
+("dotenv", "16.0.2"),
+("helmet", "^6.0.0"),
+("patch-package", "^6.4.7"),
+("uuid", "^9.0.0"),
+("lodash.merge", "^4.6.2"),
+("rate-limiter-flexible", "^2.4.1"),
+("superjson", "^1.12.2")
+
+depsRequiredByPassport spec
+
+depsRequiredByJobs spec
+
+depsRequiredByEmail spec
+
+depsRequiredByWebSockets spec,
+ N.waspDevDependencies =
+ AS.Dependency.fromList
+ [ ("nodemon", "^2.0.19"),
+ ("standard", "^17.0.0"),
+ ("prisma", show prismaVersion),
+ -- TODO: Allow users to choose whether they want to use TypeScript
+ -- in their projects and install these dependencies accordingly.
+ ("typescript", "^5.1.0"),
+ ("@types/express", "^4.17.13"),
+ ("@types/express-serve-static-core", "^4.17.13"),
+ ("@types/node", "^18.11.9"),
+ ("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"),
+ ("@types/uuid", "^9.0.0"),
+ ("@types/cors", "^2.8.5")
+ ]
+ }
+
+
+LOG:
+- react moved from web-app to project package.json
+- react-dom moved from web-app to project package.json
diff --git a/waspc/data/Generator/templates/server/src/entities/index.ts b/waspc/data/Generator/templates/sdk/entities/index.ts
similarity index 100%
rename from waspc/data/Generator/templates/server/src/entities/index.ts
rename to waspc/data/Generator/templates/sdk/entities/index.ts
diff --git a/waspc/data/Generator/templates/sdk/operations/index.ts b/waspc/data/Generator/templates/sdk/operations/index.ts
new file mode 100644
index 0000000000..31e70ae98b
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/operations/index.ts
@@ -0,0 +1,22 @@
+import api, { handleApiError } from 'wasp/api'
+import { HttpMethod } from 'wasp/types'
+import {
+ serialize as superjsonSerialize,
+ deserialize as superjsonDeserialize,
+} from 'superjson'
+
+export type OperationRoute = { method: HttpMethod, path: string }
+
+export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
+ try {
+ const superjsonArgs = superjsonSerialize(args)
+ const response = await api.post(operationRoute.path, superjsonArgs)
+ return superjsonDeserialize(response.data)
+ } catch (error) {
+ handleApiError(error)
+ }
+}
+
+export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
+ return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
+}
diff --git a/waspc/data/Generator/templates/sdk/operations/resources.js b/waspc/data/Generator/templates/sdk/operations/resources.js
new file mode 100644
index 0000000000..5261654600
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/operations/resources.js
@@ -0,0 +1,81 @@
+import { queryClientInitialized } from 'wasp/rpc/queryClient'
+import { makeUpdateHandlersMap } from './updateHandlersMap'
+import { hashQueryKey } from '@tanstack/react-query'
+
+// Map where key is resource name and value is Set
+// containing query ids of all the queries that use
+// that resource.
+const resourceToQueryCacheKeys = new Map()
+
+const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
+/**
+ * Remembers that specified query is using specified resources.
+ * If called multiple times for same query, resources are added, not reset.
+ * @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
+ * @param {string[]} resources - Names of resources that query is using.
+ */
+export function addResourcesUsedByQuery(queryCacheKey, resources) {
+ for (const resource of resources) {
+ let cacheKeys = resourceToQueryCacheKeys.get(resource)
+ if (!cacheKeys) {
+ cacheKeys = new Set()
+ resourceToQueryCacheKeys.set(resource, cacheKeys)
+ }
+ cacheKeys.add(queryCacheKey)
+ }
+}
+
+export function registerActionInProgress(optimisticUpdateTuples) {
+ optimisticUpdateTuples.forEach(
+ ({ queryKey, updateQuery }) => updateHandlers.add(queryKey, updateQuery)
+ )
+}
+
+export async function registerActionDone(resources, optimisticUpdateTuples) {
+ optimisticUpdateTuples.forEach(({ queryKey }) => updateHandlers.remove(queryKey))
+ await invalidateQueriesUsing(resources)
+}
+
+export function getActiveOptimisticUpdates(queryKey) {
+ return updateHandlers.getUpdateHandlers(queryKey)
+}
+
+export async function invalidateAndRemoveQueries() {
+ const queryClient = await queryClientInitialized
+ // If we don't reset the queries before removing them, Wasp will stay on
+ // the same page. The user would have to manually refresh the page to "finish"
+ // logging out.
+ // When a query is removed, the `Observer` is removed as well, and the components
+ // that are using the query are not re-rendered. This is why we need to reset
+ // the queries, so that the `Observer` is re-created and the components are re-rendered.
+ // For more details: https://github.com/wasp-lang/wasp/pull/1014/files#r1111862125
+ queryClient.resetQueries()
+ // If we don't remove the queries after invalidating them, the old query data
+ // remains in the cache, casuing a potential privacy issue.
+ queryClient.removeQueries()
+}
+
+/**
+ * Invalidates all queries that are using specified resources.
+ * @param {string[]} resources - Names of resources.
+ */
+async function invalidateQueriesUsing(resources) {
+ const queryClient = await queryClientInitialized
+
+ const queryCacheKeysToInvalidate = getQueriesUsingResources(resources)
+ queryCacheKeysToInvalidate.forEach(
+ queryCacheKey => queryClient.invalidateQueries(queryCacheKey)
+ )
+}
+
+/**
+ * @param {string} resource - Resource name.
+ * @returns {string[]} Array of "query cache keys" of queries that use specified resource.
+ */
+function getQueriesUsingResource(resource) {
+ return Array.from(resourceToQueryCacheKeys.get(resource) || [])
+}
+
+function getQueriesUsingResources(resources) {
+ return Array.from(new Set(resources.flatMap(getQueriesUsingResource)))
+}
diff --git a/waspc/data/Generator/templates/sdk/operations/updateHandlersMap.js b/waspc/data/Generator/templates/sdk/operations/updateHandlersMap.js
new file mode 100644
index 0000000000..8c43c0b1ba
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/operations/updateHandlersMap.js
@@ -0,0 +1,37 @@
+export function makeUpdateHandlersMap(calculateHash) {
+ const updateHandlers = new Map()
+
+ function getHandlerTuples(queryKeyHash) {
+ return updateHandlers.get(queryKeyHash) || [];
+ }
+
+ function add(queryKey, updateQuery) {
+ const queryKeyHash = calculateHash(queryKey)
+ const handlers = getHandlerTuples(queryKeyHash);
+ updateHandlers.set(queryKeyHash, [...handlers, { queryKey, updateQuery }])
+ }
+
+ function getUpdateHandlers(queryKey) {
+ const queryKeyHash = calculateHash(queryKey)
+ return getHandlerTuples(queryKeyHash).map(({ updateQuery }) => updateQuery)
+ }
+
+ function remove(queryKeyToRemove) {
+ const queryKeyHash = calculateHash(queryKeyToRemove)
+ const filteredHandlers = getHandlerTuples(queryKeyHash).filter(
+ ({ queryKey }) => queryKey !== queryKeyToRemove
+ )
+
+ if (filteredHandlers.length > 0) {
+ updateHandlers.set(queryKeyHash, filteredHandlers)
+ } else {
+ updateHandlers.delete(queryKeyHash)
+ }
+ }
+
+ return {
+ add,
+ remove,
+ getUpdateHandlers,
+ }
+}
diff --git a/waspc/data/Generator/templates/sdk/package.json b/waspc/data/Generator/templates/sdk/package.json
new file mode 100644
index 0000000000..d3e692dc5d
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/package.json
@@ -0,0 +1,87 @@
+{{={= =}=}}
+{
+ "name": "wasp",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist"
+ },
+ "exports": {
+ {=! todo(filip): Check all exports when done with SDK generation =}
+ {=! Some of the statements in the comments might become incorrect. =}
+ {=! "our code" means: "web-app", "server" or "SDK", or "some combination of the three". =}
+ {=! Used by users, documented. =}
+ "./core/HttpError": "./dist/core/HttpError.js",
+ "./core/AuthError": "./dist/core/AuthError.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./core/config": "./dist/core/config.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./core/stitches.config": "./dist/core/stitches.config.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./core/storage": "./dist/core/storage.js",
+ "./core/auth": "./dist/core/auth.js",
+ {=! Used by users, documented. =}
+ "./rpc": "./dist/rpc/index.js",
+ {=! Used by users, documented. =}
+ "./rpc/queries": "./dist/rpc/queries/index.js",
+ {=! Used by users, documented. =}
+ "./rpc/actions": "./dist/rpc/actions/index.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./rpc/queryClient": "./dist/rpc/queryClient.js",
+ {=! Used by users, documented. =}
+ "./types": "./dist/types/index.js",
+ {=! Used by users, documented. =}
+ "./auth/login": "./dist/auth/login.js",
+ {=! Used by users, documented. =}
+ "./auth/logout": "./dist/auth/logout.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./auth/user": "./dist/auth/user.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./auth/session": "./dist/auth/session.js",
+ {=! Not sure who uses this, ask Miho. Our code definitely does, I don't know about the users =}
+ "./auth/utils": "./dist/auth/utils.js",
+ {=! Used by users, documented. =}
+ "./auth/forms/Login": "./dist/auth/forms/Login.jsx",
+ {=! Used by users, documented. =}
+ "./auth/forms/Signup": "./dist/auth/forms/Signup.jsx",
+ {=! Not sure who uses this, ask Miho. Our code definitely does, I don't know about the users =}
+ "./auth/pages/createAuthRequiredPage": "./dist/auth/pages/createAuthRequiredPage.jsx",
+ {=! Used by users, documented. =}
+ "./api": "./dist/api/index.js",
+ {=! Parts are used by users, documented. Parts are probably used by our code, undocumented (but accessible). =}
+ "./api/*": "./dist/api/*",
+ {=! Used by users, documented. =}
+ "./operations": "./dist/operations/index.js",
+ {=! If we import a symbol like "import something form 'wasp/something'", we must =}
+ {=! expose it here (which leaks it to our users). We could avoid this by =}
+ {=! using relative imports inside SDK code (instead of library imports), =}
+ {=! but I didn't have time to implement it. =}
+ "./ext-src/*": "./dist/ext-src/*.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./operations/*": "./dist/operations/*",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./universal/url": "./dist/universal/url.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./universal/types": "./dist/universal/types.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./universal/validators": "./dist/universal/validators.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./server/dbClient": "./dist/server/dbClient.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./server/config": "./dist/server/config.js",
+ {=! Parts are used by users, documented. Parts are probably used by our code, undocumented (but accessible). =}
+ "./server/utils": "./dist/server/utils.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./server/actions": "./dist/server/actions/index.js",
+ {=! Used by our code, uncodumented (but accessible) for users. =}
+ "./server/queries": "./dist/server/queries/index.js"
+ },
+ "license": "ISC",
+ "include": [
+ "src/**/*"
+ ],
+ {=& depsChunk =},
+ {=& devDepsChunk =}
+}
diff --git a/waspc/data/Generator/templates/sdk/rpc/actions/core.d.ts b/waspc/data/Generator/templates/sdk/rpc/actions/core.d.ts
new file mode 100644
index 0000000000..ea41a0eed3
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/rpc/actions/core.d.ts
@@ -0,0 +1,13 @@
+import { type Action } from '.'
+import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types'
+
+export function createAction(
+ actionRoute: string,
+ entitiesUsed: unknown[]
+): ActionFor
+
+type ActionFor = Expand<
+ Action[0], _Awaited<_ReturnType>>
+>
+
+type GenericBackendAction = (args: never, context: any) => unknown
diff --git a/waspc/data/Generator/templates/sdk/rpc/actions/core.js b/waspc/data/Generator/templates/sdk/rpc/actions/core.js
new file mode 100644
index 0000000000..cd1c60ecef
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/rpc/actions/core.js
@@ -0,0 +1,37 @@
+import { callOperation, makeOperationRoute } from 'wasp/operations'
+import {
+ registerActionInProgress,
+ registerActionDone,
+} from 'wasp/operations/resources'
+
+// todo(filip) - turn helpers and core into the same thing
+
+export function createAction(relativeActionRoute, entitiesUsed) {
+ const actionRoute = makeOperationRoute(relativeActionRoute)
+
+ async function internalAction(args, specificOptimisticUpdateDefinitions) {
+ registerActionInProgress(specificOptimisticUpdateDefinitions)
+ try {
+ // The `return await` is not redundant here. If we removed the await, the
+ // `finally` block would execute before the action finishes, prematurely
+ // registering the action as done.
+ return await callOperation(actionRoute, args)
+ } finally {
+ await registerActionDone(entitiesUsed, specificOptimisticUpdateDefinitions)
+ }
+ }
+
+ // We expose (and document) a restricted version of the API for our users,
+ // while also attaching the full "internal" API to the exposed action. By
+ // doing this, we can easily use the internal API of an action a users passes
+ // into our system (e.g., through the `useAction` hook) without needing a
+ // lookup table.
+ //
+ // While it does technically allow our users to access the interal API, it
+ // shouldn't be a problem in practice. Still, if it turns out to be a problem,
+ // we can always hide it using a Symbol.
+ const action = (args) => internalAction(args, [])
+ action.internal = internalAction
+
+ return action
+}
diff --git a/waspc/data/Generator/templates/sdk/rpc/actions/index.ts b/waspc/data/Generator/templates/sdk/rpc/actions/index.ts
new file mode 100644
index 0000000000..2be33b3d65
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/rpc/actions/index.ts
@@ -0,0 +1,14 @@
+import { createAction } from './core'
+import { CreateTask, UpdateTask } from 'wasp/server/actions'
+
+export const updateTask = createAction('operations/update-task', [
+ 'Task',
+])
+
+export const createTask = createAction('operations/create-task', [
+ 'Task',
+])
+
+export const deleteTasks = createAction('operations/delete-tasks', [
+ 'Task',
+])
diff --git a/waspc/data/Generator/templates/sdk/rpc/index.ts b/waspc/data/Generator/templates/sdk/rpc/index.ts
new file mode 100644
index 0000000000..8a743e3456
--- /dev/null
+++ b/waspc/data/Generator/templates/sdk/rpc/index.ts
@@ -0,0 +1,338 @@
+import {
+ QueryClient,
+ QueryKey,
+ useMutation,
+ UseMutationOptions,
+ useQueryClient,
+ useQuery as rqUseQuery,
+ UseQueryResult,
+} from "@tanstack/react-query";
+export { configureQueryClient } from "./queryClient";
+
+export type Query = {
+ (queryCacheKey: string[], args: Input): Promise